Document journey developing the first MVP

Lonami 2022-10-12 21:34:45 +02:00
commit 7e016b7a0c
1 changed files with 218 additions and 0 deletions

218
Journey.md Normal file

@ -0,0 +1,218 @@
# 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"](https://ddg.gg/android%20development%20kotlin) which lands us on [Develop Android apps with Kotlin](https://developer.android.com/kotlin/). Perfect!
The first step is to download [Android Studio](https://developer.android.com/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](https://developer.android.com/jetpack/compose/why-adopt)?
> 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?](https://www.bloco.io/blog/7-things-about-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](https://developer.android.com/courses/android-development-with-kotlin/course) and [Android Basics with Compose](https://developer.android.com/courses/android-basics-compose/course). I recommend you to follow them as much as you need before actually creating the project. You can use a [Kotlin Playground](https://play.kotlinlang.org) 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](https://developer.android.com/courses/android-basics-compose/course).
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](https://developer.android.com/jetpack/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](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md#naming-unit-composable-functions-as-entities) (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](https://developer.android.com/guide/topics/resources/string-resource), 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](https://developer.android.com/jetpack/compose/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](https://kotlinlang.org/docs/delegated-properties.html).
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](https://developer.android.com/codelabs/basic-android-kotlin-compose-write-automated-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](https://developer.android.com/codelabs/basic-android-kotlin-compose-training-change-app-icon), [theming](https://developer.android.com/courses/pathways/android-basics-compose-unit-3-pathway-3), 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](https://developer.android.com/codelabs/basic-android-kotlin-compose-viewmodel-and-state) 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`!
```kotlin
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](https://developer.android.com/codelabs/basic-android-kotlin-compose-test-cupcake) 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](https://esolangs.org/wiki/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](https://medium.com/visly/rust-on-android-19f34a2fb43) seems like a promising start. It first tells us to add the required Android targets via `rustup`:
```sh
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:
```sh
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`](https://doc.rust-lang.org/cargo/reference/config.html) if it doesn't exist already, however. Inside, we'll add the targets for the NDK toolchain:
```toml
[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](https://doc.rust-lang.org/cargo/reference/config.html#targettriplear) 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:
```sh
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](https://stackoverflow.com/q/68873570/). There is a [wonderful workaround posted as a GitHub comment](https://github.com/rust-lang/rust/pull/85806#issuecomment-1096266946) which consists of creating `libgcc.a` files with
```a
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](https://github.com/rust-lang/rust/pull/85806#issuecomment-851030534). It seems to be the solution [used by others](https://github.com/rust-windowing/android-ndk-rs/pull/189/files) as well. [It's a sad state of affairs](https://github.com/rust-lang/rust/pull/85806#issuecomment-1171841163). 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](https://github.com/bbqsrc/cargo-ndk) seems to make the whole process easier (and presumably [doesn't even need the ugly workaround](https://github.com/bbqsrc/cargo-ndk/issues/22)). 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](https://gitlab.com/dpezely/native-android-kotlin-rust), 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](https://medium.com/visly/cross-platform-rust-406fddd0185b) post.
I don't have much experience writing Gradle scripts. But I've found a [Rust Android Gradle Plugin](https://github.com/mozilla/rust-android-gradle) 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:
```kotlin
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
}
```