Writing an Android app with Scala.js
Scala on Android
I've been planning to try to write an Android App in Scala for a long time.
When I started writing Scala (circa 2014) there was quite some buzz about writing android apps in Scala.
Later in 2016, with the release of Scala 2.12, the compiler started using some new VM features from Java 8, which made it increasingly harder to compile for Android, with Android projects effectively being stuck in 2.11.
However, there are still alternative ways to write Android apps. One of the simplest (which is what I'll be writing about) is using Scala.js and Electron. While Electron apps are not ideal, this approach is still good enough for most small applications.
So, here's a post about what went well and what went wrong during this process. Hopefully this will be helpful to others wanting to write an app in Scala.
The current app is not yet polished to a point where I would feel comfortable releasing on the Play Store, but you can check the result on the GitHub repo and get the APK from the releases page.
Coming up with an idea for an App
Recently, while watching a James Hoffmann video about coffee apps, he briefly mentioned a lack of free caffeine tracker apps for Android.
I was a bit surprised by this, and it seemed like a simple idea to execute, so I decided to try it out.
Now, before I started, I did check and there's actually a free app with premium features: CaffeInMe. I've been using it for a while to know what features are expected in an app like this and it's quite more than what I expected.
The bottom line being, if you actually want a caffeine tracker app, you are better off with CaffeInMe than with my blogware.
Base architecture
Tech Stack
Scala.js
Scala.js is an alternative backend for the Scala programming language that, as the name implies, compiles Scala code to JS (with plans to also support WASM). Also, it makes it very easy to interop with native JS code if needed.
I've been playing around with it in the last couple of years to cross-compile my game jam entries for the Web, and it's a pretty solid piece of tech.
As described above, the plan is to target JS and wrap the result with Electron, so this was an obvious choice.
Tyrian
Tyrian is a Scala.js framework to write apps in the style of The Elm Architecture (TEA).
If you are not familiar with it, basically your application/components are comprised of:
-
A
Model
type, which your application/component data representation -
A
Message
type, which represents the actions that can change your application/component state -
An
update: (Model, Message) => Model
function, that models state transitions -
A
view: Model => Html
function that decides how the current state should be renderedIn reality, the architecture and function signature is a bit more complex than this (e.g. there's also the notion of
Command
s to trigger side effects), but it's not too complicated.I had my eyes on Tyrian for a while and was quite hyped to do something with it, especially after LambdaDays 2024. Turns out that writing UIS using TEA-based architectures is super hot right now on the FP circles .
One of the main differences of Tyrian vs Elm is that Tyrian is much less opinionated and has less guard rails than Elm. While this is a double edged sword, I consider this a plus, especially for small apps.
I would need another blog post to explain why I love this, but I think the LogLogGames' "Leaving Rust gamedev after 3 years" post covers most of my thoughts. In general, I think for UI-heavy apps it's nice to be able to write quick and dirty unsafe prototypes to try out the feel of something, and writing safe clean code after validating the idea.
Circe
I need to be able to load and save the data, so I went with Circe for JSON encoding and decoding.
I could have picked any other lib, but this is the one I'm more comfortable with.
Circe made it super easy to write the JSON codecs - for the most part, I just needed to add a derives Codec
and things just worked.
Data Model
I started with a very basic data model. I knew that there were a few things that I needed for sure:
I needed a way to represent a Drink
(espresso, tea, Coca-Cola...), with the respective caffeine content. I also associated the common serving sizes to each drink, to aid the user.
final case class Drink(
name: String,
caffeinePerMl: Double,
commonSizes: List[(String, Double)]
) derives Codec
A CheckIn
, which is just a drink, a timestamp and a quantity, to represent when a certain drink was consumed. Here the time is represented as an OffsetDateTime
, as I want to be able to group check ins by local day.
final case class CheckIn(
drink: Drink,
dateTime: OffsetDateTime,
quantity: Double
) derives Codec
A History
object, which is just a container for all check ins.
// Note: this one actually needed a custom Codec
final case class History(
checkIns: SortedMap[LocalDate, List[CheckIn]] = SortedMap.empty
) derives Codec
And finally, some Settings
, so that the user can adjust how the caffeine calculation is used.
final case class Settings(
caffeineHalfLifeHours: Int = 5,
caffeineIngestionMinutes: Int = 30,
minCaffeine: Int = 100,
maxCaffeine: Int = 400
) derives Codec
I also later needed some way to model the status of modals, so I went with an abstraction that roughly looked like this
trait Modal[T]:
case class Model(open: Boolean, data: T, scratch: T)
One annoying thing about the Elm architecture that usually stumps beginners (like me) is how to handle state that's shared between components. For example, in this app, there are multiple components that need to access and edit the History
.
I admit that I'm not sure what's the best way to solve this, so in the end I just let each modal own the data and other components receive the History
directly.
In the end, the application model just looked like this:
final case class Model(
checkIns: CheckInModal.Model = CheckInModal.init,
settings: SettingsModal.Model = SettingsModal.init
)
Handling time
As mentioned before, I wanted to use OffsetDateTime
to store the check in time, so that I could have both the Instant
(to compute the caffeine levels at a point in time) and LocalDate
(to group check ins by date).
Scala.js does not come out of the box with the java.time
APIs to keep the bundle size small. As such, I needed to use scala-java-time
and sbt-tzdb
to download the time zone database.
sbt-tzdb
does allow one to pick a subset of time zones, but I'm not sure if that's a good idea for an Android app that can be used from anywhere in the world, so I just enabled everything.
To be fair, I think the size of the full app is not that bad for something that you download once (~1.7MB of JS that ends up in a ~7MB app). It could be a problem for web apps, though.
UI
Tech Stack
MDUI
While I'm technically a full stack developer, in reality I work mostly in backend systems. My frontend experience is quite limited and my CSS skills are pretty atrocious.
As such, I knew from the get go that I wanted something like Bootstrap, but for Material Design.
I started by looking at the official Material 3 documentation, which pointed me to the Material Web library.
I'm a bit confused by this library though... I struggled a bit with the lack of components, which I assumed that was related to version 2 being recently released. However, it looks like the library is in maintenance mode... I don't know, I guess Google is just weird some times.
So, I looked for alternatives and ended up with MDUI. It has a bunch more components was quite simple to use.
It's not without it's faults, however. I bumped into a bunch of edge cases where the layout just broke... It was not too bad, but I wouldn't say that "it just works".
One thing to note is that both those libraries work with Web Components, and Tyrian's APIs are not great in this regard. I raised an issue about it and maybe I'll try to send a PR with my helpers (it was not that hard to get around the problem).
Minart
I needed something to draw the caffeine plot, so I went with my own library: Minart.
I did this mainly for two reasons:
-
I wanted to write a quick prototype first, and I'm already familiar with this API;
-
I wanted to focus the 0.6.1 release on interop with other libraries, so this was a nice way to test that
Having said that, while I was developing the app, Chartreuse was released, which would probably be a more appropriate choice. However, the library is still in a bit of an early stage, so I didn't want to risk migrating the code.
Components
The code is split in just a few components:
A StatsCard
that, given a History
and Settings
, shows the caffeine plot and some stats, such as the current caffeine level and average caffeine consumption.
A CheckInHistory
that, given a History
, shows a set of cards (one per day) with the check ins performed on that day. Each check in also includes a button to remove or edit the check in. Clicking those buttons sends a message to open the CheckInModal
.
The previously mentioned CheckInModal
, to edit check ins, along with a SettingsModal
, to handle settings changes. Both components extend an abstract Modal
component, that takes care of showing/hiding the modal, updating the data (or keeping the old one) and saving/loading data from the local storage.
The Modal
abstraction looks somewhat like this:
trait Modal[T](using Codec[T]):
// Parameters to load the initial data of the model
def localStorageKey: String
def defaultValue: T
// The model keeps track if the modal is open, what's the current commited
// data and what's the data in the form
// Some helper methods omitted for brevity.
case class Model(open: Boolean, data: T, scratch: T)
// Load data from the local storage or use the default, omitted for brevity
def init: Model = ???
// Logic to handle the messages, omitted for brevity
def update(msg: Msg, model: Model): Model = ???
// Each component only needs to implement the view
def view(model: Model): Html[Msg]
// Messages to manipulate the modal
enum Msg:
case Open(scratch: T)
case Close
case Save
case UpdateAndSave(f: T => T)
case UpdateScratch(f: T => T)
case NoOp
Overall, I think the abstraction worked pretty well.
One thing to note is that I'm performing side effects on init
and update
to read and write to the local storage. The clean way to do this would be to use the built-in LocalStorage
command, but I'm not sure if the extra complexity is worth it here.
Plotting the data
Plotting the current caffeine levels was also not particularly hard.
I have some helper methods in the CheckIn
and History
to compute the caffeine levels at a certain point in time, considering a linear ingestion and exponential decay.
I start by generating a 1024 x 256 surface in Minart, backed by a native JS ImageData
. Then, for each column in the image, I fill a 3x3 rectangle on that position. Pretty easy and, for the ranges in question, works pretty well:
val baseSurface =
ImageDataSurface.fromImage(new dom.Image(imageWidth, imageHeight))
baseSurface.fill(backgroundColor)
// Draw plot
(0 until imageWidth).foreach(pixelX =>
val deltaT = (secondsPerPixel * pixelX).toInt
val caffeine =
history.caffeineAt(Instant.ofEpochSecond(start + deltaT), settings)
val pixelY = (imageHeight - 1) - (caffeine * pixelsPerMilligram).toInt
baseSurface.fillRegion(
x = pixelX - thickness,
y = pixelY - thickness,
w = 2 * thickness,
h = 2 * thickness,
color = plotColor
)
)
Then I just need to draw the grid (which is easy, it's just straight lines) and convert it to a Image
element with a simple baseSurface.toImage()
.
Bundling and Packaging
Tech Stack
Parcel
I'm using Parcel as a JS bundler.
To be honest, I have absolutely no opinion about it. As mentioned before, frontend is not my strong suit, so I just use whatever tool I have at hand. In this case, I picked it because that's what the Tyrian Giter8 template uses.
I've seen some people in the Scala Discord mentioning that they had some issues with it, but so far it has worked well for me, so no reason to change it.
Capacitor
Finally, I used Capacitor to build the final Android executable.
I don't have enough experience with tools like this to judge, but it felt simple enough to use. I did notice some weird things in the setup, so I'm not sure if I did something wrong, but it works.
Bundling the JS
As mentioned before, Parcel pretty much worked out of the box. There are, however, a few things to keep in mind when using it for an app, namely to have everything work offline.
I recommend testing the app in airplane mode to make sure that nothing is missing.
One weird thing that I stumbled upon is that, for some reason, using import 'mdui/mdui.css'
in the javascript body, as recommended in the MDUI documentation, doesn't seem to work. I'm not sure what's the reason (as Parcel seems to support this), but this is easy to work around by using @import "npm:mdui/mdui.css";
inside a style tag.
Another thing to take into account is web fonts. From what I can tell, Android already comes bundled with Roboto, so that's nice, but I still needed to download the Material Icons font and CSS. Nothing too cumbersome, just something to remember.
And, as a last note, the Tyrian template comes configured to use the output from fastLinkJS
. We need to remember to use the output of fullLinkJS
instead for the final release.
Building the App
To build the Android app, I mostly followed the Capacitor tutorial, although I hit some snags along the way:
First of all, I can't get the Android emulator to launch... I'm not sure what's going on, but if I run cap run android
my PC just starts to heat up while trying to launch it and nothing happens... I was really confused at first, until I tried it with my phone and everything worked.
The second issue was updating the icon/splash screen. It's nothing too hard, but the documentation is super short, and I got a bit confused about what files were needed and which ones were not. Along with the Android 12+ splash screen changes I ended up with a config where I could see the image files in the resources, but the default logo was used everywhere.
The documentation in the capacitor-assets
README is much better.
Releasing/Signing the App
While the app is not yet polished to a point where I would feel comfortable releasing it on the Play Store, I still wanted a signed release build to be able to distribute as an APK on GitHub.
I did bump into multiple issues here, though.
First, I saw some documentation on how to generate the keys from Android Studio, but the UI was outdated, so I couldn't find the option. In the end, I just asked Gemini (I had the assistant pane open, so it was worth the try), and it just gave me the commands to generate a keystore with a key:
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
So far, so good!
I then filled keys in in the capacitor.config.json
:
{
"android": {
"buildOptions": {
"releaseType": "APK",
"keystorePath": "FILL ME",
"keystorePassword": "FILL ME",
"keystoreAlias": "FILL ME",
"keystoreAliasPassword": "FILL ME"
}
}
I find it a bit weird to have to write the password in a config file like this, but OK... I just wanted to try this out.
Finally, I built the release APK with cap build android
, sent it to my phone and... It didn't work!
No matter what I tried, I was unable to launch a signed release generated with cap build android
on my phone (invalid package). Googling the issue didn't help and now Gemini was absolutely useless (there are safeguards to not give advice regarding security).
Looking again at the Capacitor documentation, they do mention:
After
sync
, you are encouraged to open your target platform's IDE: Xcode for iOS or Android Studio for Android, for compiling your native app.
And, indeed, building and signing the app from Android studio just worked. So I guess that's the way to go .
Final notes
Overall, I would say that the experience was quite enjoyable. I was pleasantly surprised with how Tyrian just makes some refactoring tasks trivial, which makes experimentation a joy.
MDUI was also quite pleasant to work with. Even though I spent some time banging my head against some components that didn't work as expected, most things worked pretty well. I think I would prefer a more strongly opinionated alternative, though.
Using Parcel and Capacitor also wasn't too bad. It allowed me to quickly check the final app both on my browser and phone. It's a bit of a shame that I had to bump my head against a wall a few times until I got Capacitor working.