Let's make a messaging application!
Are you growing old and tired of the extra "features" messaging applications keep adding? Do you have some free time in your hands? No? Oh.
I really like Telegram. It's free and proved very useful over the years. But I can't get over some of the new features which I personally consider bloat. I would like more control over my messaging experience. So I'm making my own, like any (in)sane person would do.
In this post I'll go over the environment setup required before we can get our hands dirty. I will be doing this on Windows, because I'm lazy. I want to use Kotlin too which I haven't used seriously in the past, so the first thing is obviously to duck for "android development kotlin" which lands us on Develop Android apps with Kotlin. Perfect!
The first step is to download Android Studio. After the installation is complete, launch Android Studio to be greeted by the Setup Wizard. You will have to download the SDK, platform tools, build tools, and optionally configure an emulator. I will be using a real device.
Once the Android SDK is installed and configured, Android Studio will be ready to let you create a New Project. There are many templates available to choose from, from Empty Activity, to Basic, Fullscreen, Login (that could be useful...), Tabbed, Settings (ohh), Scrolling (can't we use all of them?)... But those would be too easy! Hey, what's this "Compose" thing? Why Compose?
Jetpack Compose is Android's modern toolkit for building native UI. It simplifies and accelerates UI development on Android bringing your apps to life with less code, powerful tools, and intuitive Kotlin APIs. It makes building Android UI faster and easier. While creating Compose we worked with different partners who experienced all of these benefits first hand and shared some of their takeaways with us.
Alright, sounds good. But that's official documentation, so of course they will market it under a good light. Are there other things I should know before using Compose?:
Should I use Jetpack Compose on my next Android project?
It depends, as always. As with most technologies and development choices, there's no one tool to solve all problems.
If you are starting a side project you should definitely give it a try. And learn what might be the future of modern Android development.
I'm sold! Assuming this will be the New Cool Thing, it might be worth learning. The tight Kotlin integration makes it a nice bonus, as it will help me learn the language better too and how APIs are designed.
There are courses for both Android Development with Kotlin and Android Basics with Compose. I recommend you to follow them as much as you need before actually creating the project. You can use a Kotlin Playground to play with Kotlin without having to use any sort of IDE.
Back to the New Project Wizard. We'll select Empty Compose Activity and go from there. The IDE should open your new project with an empty MainActivity
. It's a simple hello world. Try running it to make sure it's all properly configured and working.
The first thing I'll do is edit the build.gradle:app
to change the versionName
. We are not going to be in 1.0 for a while. I've also gone ahead and deleted the ic_launcher.webp
files (in app/src/main/res/mipmap-*
), since I'll be trying to keep the binary files to a minimum.
You might have noticed there is a .gitignore
file, but no .git
directory. I will be using Git as my VCS, so make sure to run git init
if you want to follow along. Add all the files, and on you go with your initial commit!
When you opened the build.gradle
, you may have noticed there's a lot of warnings already. But I just created the project! How can it already have warnings? That's just how it goes. I'll be applying the suggestions to fix up the file (updating the target and compile SDK to version 33, as well as bumping all the dependencies). Two commits already! And we haven't even started! But now we're ready to get working. Until next post!
Building the login UI
The first step will be to build a login interface. We'll want the user to enter their phone number, and then the OTP sent by Telegram. For the time being, we'll assume any values are correct. Integration with the API is still a long way to come. Once the user enters the OTP, we can move to the next screen. This post assumes you're following along with your own IDE and code.
The default activity we created in the previous post is cool and all. But a measly Text
component just ain't gonna cut it. Let's take a look at the Android Basics with Compose.
One of the things you'll learn there is the @Preview
annotation. It adds a little "Run" action next to the fun
where it's used, which lets you preview different things quickly in your device. But even better is the Split or Design code views. When using these, the annotated function will be used to render the Composable within the IDE itself, without the need to run a full emulator or installing the application. This is great for quickly prototyping. The Preview itself won't actually be used in the final application (unless you also use the Composable itself, that is). The Compose tooling offers ways to know if the code is running under a preview or the real application, change the locale, among other features.
Let's rename our DefaultPreview
to LoginPreview
. There's certain conventions when it comes to naming things (pascal case, only nouns, not verbs or preopositions), but overall they are pretty intuitive.
You can get rid of the Greeting
composable and do everything with LoginPreview
until you find a need to factor it out into smaller components. I find it easier to start in a single function and split it as it grows. This lets me organically learn about the natural points where it makes sense to split the function.
I want my login UI to have the application's title, followed by a text asking the user to enter their phone number, and an input field where they can do so. We can put this all within the same Column
so that things are stacked on top of each other automatically. Be sure to add modifier
and horizontalAlignment
to your heart's content.
In the images codelab, you'll learn that app resources such as strings (uh) must be separated from code. It's a good idea to do this early, so don't hesitate to edit your res/values/strings.xml
file to add as many strings as you need. Be sure to add all user-facing strings here. There are different String resources, and formatting is allowed. Make sure to properly indicate the type of your formatting parameters (as in %1$s
), or stringResource
may not understand what you mean. (Aside: you may find examples using getString
, but this seems to be the old way to do things.)
You will need to rebuild your application whenever you add or rename resources. In my experience, the Design preview can be a bit buggy sometimes (with text going where it doesn't belong, spacing not really updating correctly, alignment being wonky). If this happens, simply rebuild the application.
State in compose is just plain old variables. You can hoist both the values and callbacks to be parameters of your Composable functions, so that the state can be shared between components. Note that in order to use the by remember
syntax and mutableStateOf
to manage state, be sure to also import getValue
and setValue
from androidx.compose.runtime
. It is rather obscure, but the imports are needed for its usage to work (even if you don't seem to use it directly). The way it works underneath is through the use of Kotlin's Delegated Properties.
Composer will use these "observables" to automatically re-compose your application on any state change. remember
is used because Composable functions only declare the UI and state would be lost otherwise (state without remember
would be reset when the function is executed again without). You can think of it as lazy initialization, like some sort of OnceCell
. In addition to that, by remember
enables you to directly assign and read from the declared variable, without having to access the inner .value
of the mutableStateOf
result (this is why getValue
and setValue
must be imported).
State derived from a source state does not need to be declared as mutableStateOf
or anything like that. Those can be plain expressions. However, if state should be kept even when the activity is recreated (e.g. screen rotates), rememberSaveable
should be used instead.
While writing instrumentation tests for your UI now would probably be a good idea, I'm going to skip that.
In the next post, we'll begin working on different screens, and learn how to connect them.
Connecting different screens
Before we can connect different screens, we must have different screens. A messaging application needs a list of contacts or otherwise open conversations, as well as a place to read previous messages and send new ones. We will not concern ourselves with application icons, theming, animations or even top bars just yet.
For the list of conversations (henceforth called "dialogs", for consistency with Telegram's terminology), we'll need a data class which holds all of the relevant information (name, last message, pinned state, and eventually profile photo). The codelab recommends to create these data classes in a new package, model
, which will contain these data models, and data
, responsible for loading the data.
Be sure to annotate the properties of your dataclasses with @StringRes
or @DrawableRes
as you see fit for better IDE support. We don't currently need this, because profile names and photos won't come from resources (except maybe default profile pictures, but that's a battle for another day).
Note that when making use of a LazyColumn
, the codelab code does not directly work (perhaps updating the dependencies in the first post is coming back to bite me, but I'm pretty sure only the bumped the minor version, so if that's the case, what's up with the breaking changes?!). The items
function expects a count, and the function to generate contents gets access to an index (arguably, this makes more sense than accepting the entire list, since this means the list can be generated on the fly).
Once you have a Composable function for the login, dialogs and chat screen, we can start to clean up the MainActivity
file. Following the codelab, the recommendation seems to be to create one Screen.kt
file inside the ui
package of your application for each screen you have (in our case, LoginScreen.kt
, DialogScreen.kt
and ChatScreen.kt
). Move all the relevant Composables to each file, including the Previews. If the Design panel becomes stubborn, closing and opening the file should do the trick.
Next, we should probably use ViewModels to make our app robust. We'll be dealing with asynchronous operations soon, so we want the code to be as clean as we can make it. You will need to add a dependency on androidx.lifecycle:lifecycle-viewmodel-compose
in the build.gradle
of your project's primary module. The login screen is rather simple, so it can do away without one.
We can use this opportunity to add a way to "send messages" and update the view model. Just to get used to it. If you find yourself creating a component which can be shared across multiple screens, the recommendation is to create a file for the component under a ui.components
package.
Now that the code is better organized, we can add a new dependency on androidx.navigation:navigation-compose
in order to use the Jetpack Navigation component.
When defining the routes for the NavHost
via its composable
methods, LocalContext.current
can be used to access the application's resources
such as localized strings. If you wish to clear all previous navigations (accessible via the back button), the popBackStack
method on NavHostController
can be used. You can even navigate across applications (sort-of) with Intent
!
context.startActivity(Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, summary)
},
context.getString(R.string.share_post)
))
...but we're not yet at that stage. Maybe once we can actually access Telegram channels it'll make sense to add a share button.
Once navigation is in place, it would be a good idea to add tests for that, but in the interest of keeping this short (and definitely NOT because I don't want to write those), I'll skip over that.
The next step will be to add integration with Telegram's API, at long last!
Integration with Telegram's API
We'll use Rust! Hopefully. I knnow what you're thinking, "why not Connery?". Well my dear reader, that's because I just picked that language and random and wasn't even aware it existed until now. But you're welcome to use that if you feel so inclined. I'll be using Rust though.
Now, there's a few things we need to figure out. Not only do we need to get Rust running within an Android application, but we have to integrate that with Kotlin. Within the constraints of Compose. Using the Gradle build system. Configured from Android Studio. Which probably needs to invoke Cargo. Oh, and the Rust code will be using async
too. It's a process.
The post Rust on Android seems like a promising start. It first tells us to add the required Android targets via rustup
:
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android
If you haven't already, you also need to install the NDK bundle. From Android Studio > File > Settings > Appearance and Behavior > System Settings > Android SDK > SDK Tools, be sure to install NDK (Side by side). This should create a ndk
folder in your ANDROID_HOME
(wherever your Sdk
was installed, mine defaulted to %LOCALAPPDATA%\Android\Sdk
), and inside, you will find the build/tools
path with make_standalone_toolchain.py
. Because the article seems to be written for Linux, it likely assumed you had Python installed. If you don't, you will need to install that before running this script (or find where Python is inside the ndk
since it is used elsewhere).
I will be placing the Ndk-standalone
folder next to Sdk
. The compileSdk
of my build.gradle
is 33
, so I'll be using that in the command as well:
cd $Env:ANDROID_HOME/..
python .\Sdk\ndk\25.1.8937393\build\tools\make_standalone_toolchain.py --api 33 --arch arm64 --install-dir .\Ndk-standalone\arm64
WARNING:__main__:make_standalone_toolchain.py is no longer necessary. The
%NDK%\toolchains\llvm\prebuilt\windows-x86_64\bin directory contains target-specific scripts that perform
the same task. For example, instead of:
C:\>python %NDK%\build\tools\make_standalone_toolchain.py \
--arch arm64 --api 33 --install-dir toolchain
C:\>toolchain\bin\clang++ src.cpp
Instead use:
C:\>%NDK%\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android33-clang++ src.cpp
...oh alright that's convenient. We don't have to run the commands for arm
and x86
either. Nevermind the Ndk-standalone
directory then, we can get rid of that.
We do have to create ~/.cargo/config.toml
if it doesn't exist already, however. Inside, we'll add the targets for the NDK toolchain:
[target.aarch64-linux-android]
linker = "C:\\Users\\L\\AppData\\Local\\Android\\Sdk\\ndk\\25.1.8937393\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android33-clang++.cmd"
[target.armv7-linux-androideabi]
linker = "C:\\Users\\L\\AppData\\Local\\Android\\Sdk\\ndk\\25.1.8937393\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\armv7a-linux-androideabi33-clang++.cmd"
[target.i686-linux-android]
linker = "C:\\Users\\L\\AppData\\Local\\Android\\Sdk\\ndk\\25.1.8937393\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\i686-linux-android33-clang++.cmd"
The ar
option seems to be deprecated so it should no longer be necessary, and I've left it out (not that there were any -ar
files provided by the Ndk anyway, good thing I didn't need them).
Let's try making a new project:
cargo new rust-android-example --lib
(This will fail if your config.toml
is wrong, as in forgetting the backslash actually escapes characters so it wouldn't work, not that it happened to me.)
Next, edit the lib.rs
to contain the code from the post (changing the extern fn
name accordingly to your application's package name), and the project's Cargo.toml
to add a dependency on jni
. For now, we can use the 0.10.2
version of jni
since that's what the post uses, but it would be a good idea to bump that later. When you're done, run the build commands:
cargo build --target aarch64-linux-android --release
cargo build --target armv7-linux-androideabi --release
cargo build --target i686-linux-android --release
The compiled library is now ready to be used from Android Studio! Assuming it compiled, which it didn't for me at first:
error: linking with `%LOCALAPPDATA%\Android\Sdk\ndk\25.1.8937393\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android33-clang++.cmd` failed: exit code: 1
|
= note: "%LOCALAPPDATA%\\Android\\Sdk\\ndk\\25.1.8937393\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\aarch64-linux-android33-clang++.cmd" (very long list of options) "-ldl" "-llog" "-lgcc" "-ldl" "-lc" "-lm" (even more options)
= note: ld: error: unable to find library -lgcc
clang++: error: linker command failed with exit code 1 (use -v to see invocation)
Thank goodness someone else had this problem before I did. There is a wonderful workaround posted as a GitHub comment which consists of creating libgcc.a
files with
INPUT(-lunwind)
inside of them for each of the various lib targets contained in ndk\*\toolchains\llvm\prebuilt\windows-x86_64\lib64\clang\14.0.6\lib\linux\{aarch64,arm,i386,x86_64}
. I would've not figured this out on my own.
My guess is bin\clang++.exe
itself knows to look in lib64\clang\14.0.6\lib\linux
to pick up on the libraries needed, and INPUT()
is used to "redirect" the dependency on lgcc
to lunwind
, which is the new library used for unwinding. It seems to be the solution used by others as well. It's a sad state of affairs. But I'm sure the Rust community will eventually figure it out. For now I'm okay using the workaround (even if it's rather brittle and scary how easily it could break).
Now where was I... ah! If you've done the above hack correctly, the cargo build
commands should work! Copy over the build .so
files from your target/*/release
directory into your application's app/src/main/jniLibs
. Make one directory per target (arm64-v8a
, armeabi-v7a
and x86
), and lo and behold, you can actually use System.loadLibrary("rust")
and call external fun
! Fun!
While I haven't tried it, the cargo-ndk project seems to make the whole process easier (and presumably doesn't even need the ugly workaround). In any case, running cargo build
and copying over the .so
files is no fun! The blog post includes a simple bash script to run cargo build
and copy the .so
files. But as it says:
If you want to get fancy you can add this as a build step in your gradle file so it is automatically run any time you build your Android Studio project.
I like fancy. Others are brave enough to use Makefiles, but I'm not, so we'll go with Gradle.
While the code above works, it isn't super easy to work with. In a larger project we want a way to encapsulate the ugly bits of interacting with Rust from Kotlin and vice versa.
This is true, but let's first sort out the Gradle script. If you're interested though, feel free to read their Cross-platform Rust post.
I don't have much experience writing Gradle scripts. But I've found a Rust Android Gradle Plugin hosted under Mozilla's GitHub organization, which is like, perfect!
We have to configure the ANDROID_NDK_TOOLCHAIN_DIR
environment variable to point the same directory as we did in Cargo's config.toml
. Later I found out Android Studio's Project Structure also has a link to download the NDK which creates the folder ndk-bundle
, which seems to be essentially the same. I'm not sure what's up with that. It didn't let me save that path to the local.properties
anyway.
You can run ./gradlew cargoBuild
from the project's root to make sure it all works out. It should build all targets you specified in the Gradle script. Once you have the tasks configured, you should no longer need to worry about the build step at all, and can instead focus on making use of your new dependency from within Android's realm:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
System.loadLibrary("rust") // whatever your lib's name is
Log.d("rust", hello("World"))
}
external fun hello(to: String): String
}