Technical manual
This page contains a series of technical notes that can be useful for those who want to contribute to the development of the app or simply become more familiar with the project structure.
Setting up the development environment
This is a Kotlin Multiplatform (KMP) project that uses the Gradle build tool. The recommended development environment is Android Studio with the Kotlin Multiplatform Mobile plugin installed. Since the project is using Gradle 8.8 with the Android Gradle Plugin (AGP) version 8.5.0 you should use Android Studio Koala or later (have a look here for a compatibility matrix between versions of Gradle, AGP and Android Studio). Alternatively, you can try and use IntelliJ IDEA or Fleet but some extra steps may be needed to ensure everything fits and runs together.
In order for Gradle to build, you will need to have a JDK installed on your local development machine, if you are using stock Android Studio it ships with the JetBrains runtime, you could have a look in the Settings dialog under the section “Build, Execution, Deployment > Build Tools > Gradle” in the “Gradle JDK” location drop-down menu. If you want to use your custom JDK (e.g. under Linux you want to try OpenJDK instead), please make sure that it has a suitable version, according to this page, so between 8 and 21.
Finally, since building this project requires a quite lot of RAM due to its multi-module structure
and to the fact that it is quite a complex project, please make sure that the gradle.properties
file in the root folder contains proper memory settings for the JVM and the Kotlin compile daemon:
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4096M"
The first thing that Android Studio does upon first opening the project is a Gradle sync, this may take some time since at the beginning it has to download all the dependencies and build the cache. A Gradle sync is required every time:
- the Gradle wrapper is updated or some Gradle plugins are updated to a newer version;
- a new external dependency is added or an existing library is updated to a newer version;
- a new Gradle module is added to the project or whenever you edit the
settings.gradle.kts
file or anybuild.gradle.kts
file in any module of the project hierarchy.
In case it does not suggest it to you automatically, you will fine the “Sync Project with Gradle Files” button in the top left corner of the toolbar, right before the “Search Everywhere” button. The operation can be, depending on your hardware and connection speed, quite time consuming so be patient.
Project structure
This is a Gradle project, it is setup to download a Gradle distribution and resolve dependencies
according to the definitions contained in the gradle/libs.versions.toml
file.
Also, please note that in the settings.gradle.kts
file we are using the option:
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
to reference Gradle subprojects in each build.gradle.kts
with a type safe notation
e.g. implementation(projects.core.utils)
.
Tech stack
Here is a list of the technologies used in the project, with a short historical explanation in the cases where the initial choice changed over time and the reasons why the change was made.
- Dependency Injection
- The choice here is the Koin library. The main reason it was selected because of its great multiplatform support and the integration with the navigation library (which at the beginning of the project was not there yet, but was added later and proved to work great). You can find module definitions (beware, Gradle modules and Koin modules are two different concepts and should not be confused) in a `di` package inside each subproject, modules can include each other and all top-level modules are included in the shared module, more on it in "Module overview and dependencies".
- Navigation
-
For navigation the Voyager library has been
selected. Again, the choice was driven by its multi-platform support, its very practical approach
and ease to set up and get going, compared to other multi-platform libraries like Decompose that
were used in the past. Nonetheless, and this lesson was learned the hard way, easiness of use and
compactness mean that things will go smooth in the future, and as the project grew the navigation
library started to show its limits. Part of them were addressed by encapsulating the transition
logic (to push/pop screens into the navigation stack and open/close modal bottom sheets) into a
centralized component NavigationCoordinator.kt.
Something similar was done for the navigation drawer in DrawerCoordinator.kt.
Even the DI integration with Koin was not pain-free, the
:core:navigation
module contains some glue code that is used to work around some of the issues that were encountered. - Networking
- Here, at least for Android developers, no surprises: the choice was Ktor which has great multiplatform support. Instead of using Retrofit, to create network adapters the Ktorfit library is used, which uses KSP to parse annotations and generate code.
- Resource management
- Initially the project used the Moko resources library to load fonts, icons and all the localized messages used in the UI. It worked great, since in those areas Compose multiplatform missed the needed functionalities. But as long as the project grew in size and more complex KSP configurations were needed, having all modules depend on resources became unmaintainable. This is why I migrated drawable and font loading to Compose build-in system. And, for localization, the choice was the Lyricist library, which better handles dynamic language changes.
- Image loading
- This was something that was expected to be simpler but unfortunately it wasn't. In the beginning the project used the Kamel library which had a major bug while rendering large images, which took a long time to be considered. The project was already relying on Kamel for many things, from loading images on demand to Markdown rendering, so deciding to switch was not easy at all. So as a first step, Coil 2.x was adopted on Android while keeping Kamel on iOS and, finally, when Coil 3.x became stable with multiplatform support all the project has been completely migrated.
- Preference storage
- Here the choice was the Multiplatform settings libary which not only works great but also offers support for encryption.
- Primary persistence
- This project was a chance to experiment with SQLDelight (in other multiplatform projects other libraries were tested like Exposed), whereas database encryption is obtained through SQLCipher Android, formerly Android Database SQLCipher.
- Markdown rendering
- This was another part, like image loading, where KMP was at the beginning quite lacky. After having given up for some time and used Markwon (Java + Views) on the Android part of the app, I decided to give a second chance to Multiplatform Markdown Renderer which was initially user for the multiplatform source set. The project grew and matured over time and it made it possible to add custom handlers (like modular plug-ins) which made possible to support Lemmy's custom features like spoilers. The migration from multiplatform renderer to Markwon and back to multiplatform renderer was not easy, but this project is about KMP so, as a consequence, a pure Kotlin and pure Compose solution had to be preferred. Even if it implies to sacrifice some functionality.
- Video playback
-
The initial choice was to write a custom native implementation based on
Exoplayer
on Android and onAVPlayer
on iOS. This solution kind of worked but had some issues and with later updates of Compose Multiplatform it was using deprecated functions on iOS. This is why the video player was eventually migrated to ComposeMultiplatformMediaPlayer. - Theme generation
- The application allows to select a custom color and generate a Material 3 color scheme as a palette originate from that seed color. This is achieved by using the MaterialKolor library which was designed to be multiplatform and works as a charm for the purpose. Thanks!
- Reorderable lists
- The ability to reorder lists is achieved thanks to the Reorderable library which starting from version 1.3.1 has become multiplatform. This functionality is still experimental and is used only in the instance selection bottom sheet for anonymous users.
- Web view
- Initially a custom web view was implemented, relying on native views (WebView on Android and WKWebView on iOS), but in the end the project was migrated to Calf component to display portions of the Web.
Module structure
The project has different kinds of modules and, depending on the group a module belongs to, there are some rules about which other modules it can depend on.
Here is a description of the dependency flow:
:androidApp
which is the KMP equivalent of:app
module in Android-only projects) include:shared
and can include:core
modules (e.g. for navigation);:shared
is the heart of the KMP application and it virtually includes every other Gradle module as a dependency (it contains in theDiHelper.kt
files the setup of the DI, so it basically needs to see all Koin modules);:feature
modules are included by :shared and include :domain, :core and :unit modules, but they DO not include other each other nor any top level module; some unit modules are used just by one feature (e.g.:unit:postlist
is used only by:feature:home
) in some other cases multiple features use the same unit (e.g.:unit:zoomableimage
is used by both:feature:home
,:feature:search
,:feature:profile
and:feature:inbox
);:domain
modules can be used by feature and unit modules and can only include core modules; only exception is:domain:inbox
which is a thin layer on top of:domain:lemmy
so it depends on it (for inbox related functions);:unit
modules are included by feature modules (and:shared
) and sometimes by other unit modules in case of highly reusable parts of the app; the only notable violation to this rule is:core:commonui:detailopener-impl
which is a special module because it is only included by:shared
(which does the binding between:detailopener-api
and:detailopener-impl
) and it includes some unit modules but the fact of a unit module included by a core module in general should never happen (instead, the reverse is perfectly ok);:core
modules can sometimes include each other (but without cycles, e.g.:core:markdown
includes:core:commonui:components
/:core:utils
because it is a mid-level module and something similar happens with:core:persistecnce
which uses:core:preferences
/:core:appearance
) and nothing else; they are in turn used by all the other types of modules.
Top-level modules
The main module (Android-specific) is :androidApp
, which contains the Application subclass (MainApplication
) and the
main activity (MainActivity
). The latter in its onCreate(Bundle?)
invokes the MainView
Composable function which
in turns calls App
, the main entry point of the multiplatform application which is defined in the :shared
module.
:shared
is the top module of the multiplatform application, which includes all the other modules and is not included
by anything (except :androidApp
). In its commonMain
source set, this module contains App
, the application entry
point, the definition on the MainScreen
(and its ViewModel) hosting the main navigation with the bottom tab bar.
Another important part of this module resides in the platform specific source sets (androidMain
and iosMain
respectively) where two DiHelper.kt
files (one for each platform) can be found, which contain the setup of the root of
the project’s dependency injection in a platform specific way, an initialization function on iOS and a Koin module for
Android (which is included in MainApplication
).
Feature modules
These modules correspond to the main functions of the application, i.e. the sections of the main bottom navigation. In particular:
:feature:home
contains the post list tab;:feature:search
contains the Explore tab;:feature:inbox
contains the Inbox tab;:feature:profile
contains the Profile tab;:feature:settings
contains the Settings tab.
Domain modules
These are purely business logic modules that can be reused to provide application main parts:
:domain:identity
contains the repositories and use cases that are related to user identity, authorization and API configuration;:domain:lemmy
contains all the Lemmy API interaction logic and is divided into two submodules::data
contains all the domain models for Lemmy entities (posts, comments, communities, users, etc);:pagination
contains the pagination utilities for posts within feeds or comments as well as the navigation logic that allows to navigate to previous/next post within a list with automatic pagination;:repository
contains the repositories that access Lemmy APIs (through the :core:api module) and are used to manage the entities contained in the:data
module;
:domain:inbox
contains some uses cases needed to interact with the replies, mentions and private messages repositories and coordinate the interaction between inbox-related app components.
Unit modules
These modules are the building blocks that are used to create user-visible parts of the application, i.e. the various
screens, some of which are reusable in multiple points (e.g. the user detail, community detail or post detail, but also
report/post/comment creation forms, etc.). In some cases even a dialog or a bottom-sheet can become a “unit”, especially
if it is used in multiple points or contains a little more than pure UI (e.g. some presentation logic); simple pure-UI
dialogs and sheets are located in the :core:commonui:modals
module instead (but are being progressively converted to
separate units).
Here is a list of the main unit modules and their purpose:
:unit:about
contains the About this app dialog:unit:accountsettings
contains the screen of the remote account settings (web preferences):unit:ban
contains the modal bottom sheet used to ban a user from a community:unit:chat
contains the chat conversation screen:unit:choosecolor
contains the dialogs and bottom sheets used for theme/color selection:unit:communitydetail
contains the community detail screen:unit:communityinfo
contains the community info bottom sheet accessible from community detail:unit:configureswipeactions
contains the screen and bottom sheets to configure swipe actions:unit:configurecontentview
contains the screen used to configure post and comment appearance:unit:createcomment
contains the create comment form:unit:createpost
contains the create post form:unit:drafts
contains the screen uses to display post and comment drafts:unit:drawer
contains the navigation drawer:unit:filteredcontents
contains the screen to access moderated contents, liked/disliked items and bookmarks:unit:instanceinfo
contains the instance info bottom sheet with the list of communities:unit:login
contains the login modal bottom sheet:unit:manageaccounts
contains the modal bottom sheet used to change account:unit:manageban
contains the ban management screen:unit:managesubscriptions
contains the subscription management screen:unit:mentions
contains the mentions section of the Inbox tab:unit:messages
contains the private messages section of the Inbox tab:unit:moderatewithreason
contains the modal bottom sheet for moderation actions that require a reason (remove, purge, report, hide):unit:modlog
contains the moderation log screen:unit:multicommunity
contains the multi-community detail and multi-community editor screens:unit:myaccount
contains the profile logged section of the Profile tab:unit:postdetail
contains the post detail screen:unit:postlist
contains the post list (home) screen:unit:rawcontent
contains the “Raw content” dialog with the custom (Android) text toolbar:unit:replies
contains the replies section of the Inbox tab:unit:reportlist
contains the report list screen (for moderators):unit:selectcommunity
contains the dialog used to select communities (for cross-posts):unit:selectinstance
contains the bottom sheet used to change instance in anonymous mode:unit:userdetail
contains the user detail screen:unit:userinfo
contains the user information bottom sheet accessible from user detail:unit:web
contains the internal WebView screen:unit:zoomableimage
contains the image detail screen used to display full-screen images.
Core modules
These are the foundational blocks containing the design system and various reusable utilities that are called throughout the whole project. Here is a short description of them:
:core:api
contains the Ktorfit services used to interact with Lemmy APIs and all the data transfer objects (DTOs) used to send and receive data from the APIs;:core:appearance
contains the look and feel repository which exposes the information about the current theme as observable states and allows to change them;:core:architecture
contains the building blocks for the Model-View-Intent architecture used in all the screens of the application;:core:commonui
contains a series of submodules that are used to define UI components used in the app and reusable UI blocks::components
: a collection of components that represent graphical widgets:detailopener-api
: a utility module used to expose an API to centralize content opening (post detail, community, detail, user detail, comment creation and post creation):detailopener-impl
: implementation of the detail opener, this is an exception to the module architecture because it is a core module which includes unit modules so the important thing is that no one ever include this module except for:shared
;:lemmyui
: graphical components used to represent Lemmy UI (posts, comments, inbox items, etc.) and reusable subcomponents such as different types of headers, footers, cards, etc.:modals
: definition of modal bottom sheets and dialogs that have no presentation logic. This module was historically much bigger and over time components were migrated to separate units modules;
:core:markdown
contains Markdown rendering logic;core:l10n
contains all the localization messages and theL10nManager
interface which acts as a wrapper around Lyricist to load the internationalized messages;:core:navigation
contains the navigation manager used for stack navigation, bottom sheet navigation and a coordinator for the events originated by the navigation drawer;:core:notifications
contains theNotificationCenter
contract and implementation as well as the event definition, this is used as an event bus throughout the whole project;:core:persistence
contains the local database (primary storage) management logic as well as SQLDelight definitions of entities and migrations, plus all the local data sources that are used to access the database;:core:preferences
contains the shared preferences/user defaults (secondary storage) and relies on the multiplatform-settings library to offer a temporary key-value store;:core:resources
is a wrapper around the resource loading (fonts and images mainly) which used to rely on an external library and now used the built-in resource management of Compose;:core:utils
: contains a series of helper and utility functions/classes that are used in the project but were not big enough to be converted to separate domain/core modules on their own.
On second thoughts:
:core:commonui
has still too much in it, especially:modals
packages should become unit modules;:core:persistence
belongs more to domain modules, e.g.:domain:accounts
/:domain:settings
but it is implemented as a core module because is is strongly tied to SQLDelight and its generated code which provides the named queries to fetch/save data to the local DB.