# Android, Raspberry Pi and Ktor - Expanding Capabilities

## Project Overview

The goal of this experiment was to explore a **full-stack** architecture by hosting both an Android UI (**Jetpack Compose**) and a backend server (**Ktor**) within a single project on a Raspberry Pi.

At the time of development, Kotlin Multiplatform (KMP) was still in its early, unstable stages, so I was trying to find an alternative ways to share code and logic between a client and a server on a dedicated hardware device.

## Benefits of running full stack on Raspberry Pi

1. Full stack in a single project
    
    * **Streamlined deployment** - No need to switch between IDEs or manage separate projects.
        
    * **Sharing code** - similar to KMP I was able to share data classes and some business logic between the front-end and server.
        
    * **Re-using persistence layer** - I used Room in both the UI and server with Mutex, which worked well for simple scenarios.
        
2. Availability and Practical use cases
    
    * **No Li-ion battery** - the project was running 24h with an always-on display, so there was no risk of battery overheating or degrading.
        
    * **Restful API** - there are number of use cases of running restful api on the local network, but my favourite was sending reminder messages or task notes from my phone and displaying them as info banners.
        
    * **Interactive Dashboard** - My favourite dashboard is a weather forecast with reminders and calendar events. Since I’m using 7.5 Inch touch-based display, it is possible to switch between different dashboards.
        

## Which Build to choose from?

There are at least two methods for installing Android OS on the Raspberry Pi.

* **Konstakang** provides [AOSP](https://konstakang.com/devices/rpi5/) and [LineageOS](https://konstakang.com/devices/rpi5/) builds with additional features and enhancements.
    
* **Emteria** provides [Emteria.OS](https://emteria.com/kb/install-android-raspberry-pi-5-imager), which is a commercial solution, though it is free for personal use.
    

Numerous online tutorials detail the installation process for each version, so I will not discuss that here. I decided to use Emteria.OS, which I found to be more stable than LineageOS-based builds on Raspberry Pi. However, if you plan to connect sensors, cameras, or other peripherals, Konstakang's builds might be a better choice.

You can run both a [Ktor](https://ktor.io/) service and a UI within the same Android application on a Raspberry Pi. However, for administrative tasks like database updates or Ktor service maintenance, it's beneficial to create separate "**admin**" and "**user**" build **flavors**. This approach allows you to isolate and manage admin-specific use cases in a dedicated application, ensuring a cleaner separation of concerns.

```kotlin
productFlavors {
   create("server") {
       isDefault = true
       dimension = "environment"
       buildConfigField("String", "ENVIRONMENT", "\"server\"")
   }
   create("admin") {
       dimension = "environment"
       buildConfigField("String", "ENVIRONMENT", "\"admin\"")
   }
}
```

## Energy consumption

A Raspberry Pi with a 7.5-inch screen, running continuously, typically consumes between 6.0W and 6.5W. Occasional power spikes may occur depending on the running processes.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766398938778/9bf9d3b5-d8d6-4a03-9372-228c02d77239.jpeg align="center")

## Pre-installation tasks

### Stay Awake

To prevent the screen from entering sleep mode, enable the 'Stay Awake' setting. While not strictly required, this ensures the UI remains continuously visible, which is desirable for this application.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766398995083/cf92bafc-37f5-409f-b5d7-ea9099b6c92a.png align="center")

---

### IP Binding

Modern routers usually retain IP-to-MAC address mappings, reassigning the same IP when a device reconnects. However, older routers might assign a new IP to your Raspberry Pi (RP) if it goes offline and the DHCP lease expires. To prevent this, manually bind a static IP address to the RP's MAC address.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399019158/78d63b47-eb98-411e-b7ac-a8f047927156.png align="center")

---

### Disable Pause App Activity

While a foreground service inherently minimises pausing, disabling 'Pause app activity if unused' provides an additional layer of reliability.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399088706/d519c134-5466-4483-9e52-ca15d6fabeac.png align="center")

---

### Enable Notifications

While the service may function with notifications disabled, enabling them provides valuable insight into the currently running services.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399112557/925db738-0e21-4fc0-8a46-0dd3f9120c45.png align="center")

---

### Add User Permissions in Manifest

Since I'm going to connect to internet and run a foreground service with notifications, few permissions in the app manifest must be included.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399169978/cb26348a-ee4f-435b-81da-aa329d74a90d.png align="center")

---

### Add Foreground Service in Manifest

Define a foreground service with a type "dataSync", which handles data transfer operations, such as data uploading or downloading, fetching data or local file processing.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399208830/ae6589be-1441-4454-8a33-994a7ef47cd9.png align="center")

---

### Enable Insecure Connections

Since I’m just testing and playing around on my local network right now, skipping a certificate setup for insecure connections is fine. Create a *network\_security\_config.xml* file in the Android project's xml folder and specify the Raspberry Pi's IP address to allow these insecure connections for development purposes.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399265273/53c9dbce-7cff-4ca3-a733-7125eb40c328.png align="center")

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">It is crucial to remember that in a real-world, production environment, you should always enable secure connection and set up proper certificates. The setup I’m showing here is used for testing and experimentation only.</div>
</div>

**Next** - in the manifest, add this file under networkSecurityConfig

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399284676/7c9b4627-a24f-438d-8516-7c9f6e8c8e1c.png align="center")

---

## Create a Foreground Service

Creating a foreground service with a restart mechanism ensures the server will run continuously. The order matters here so…

1. Create a notification channel.
    
2. Build a notification.
    
3. Start a foreground service.
    

If the service is killed by the system, it will be recreated and **onStartCommand()** will be called with a **null** intent.

```kotlin
private fun createNotificationChannel() {
    val serviceChannel = NotificationChannel(
        CHANNEL_ID,
        "Server Service Channel",
        NotificationManager.IMPORTANCE_DEFAULT
    )
    val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
    manager.createNotificationChannel(serviceChannel)
}

private fun buildNotification(): Notification {
    return NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("Server Service")
        .setContentText("Running...")
         setSmallIcon(R.drawable.ic_download)
        .build()
}

private fun startForegroundService() {
    startForeground(NOTIFICATION_ID, buildNotification())
}

companion object {
    const val CHANNEL_ID = "ServerServiceChannel"
    const val NOTIFICATION_ID = 1
}
```

### Restart Foreground Service

When the system restarts a service that returned **START\_STICKY**, it calls **onCreate()** and then **onStartCommand()** again. In this restarted onStartCommand() call, the intent parameter will be null (there should not be any pending start commands here). Then **createNotificationChannel()** and **startForegroundService()** will re-establish the service as a foreground service with its persistent notification.

```kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    Log.d("ServerService", "restarting server service")
    startForegroundService()
    return START_STICKY
}
```

### Run the Foreground Service

The easiest way is to start the service from the main activity in the onCrated() method.

```kotlin
private fun runRestfulService() {
      val intent = Intent(this, ServerService::class.java)
      startService(intent)
}
```

At this point you should see a notification from the foreground service informing about the running server.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399312668/c68fc6e4-6813-4375-8616-c95dfab501dc.png align="center")

---

## Data aggregation and display

Before we jump into the service portion, I want to emphasize that aggregating and displaying data from APIs, while running the service concurrently, is perfectly acceptable with this setup. This is a key benefit of running an Android and Ktor service simultaneously.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399333124/c61f3bf8-b6c0-4d5c-8f23-aa0a8fbbd47a.png align="center")

---

## Local CRUD operations

While my Google and Outlook calendars contain numerous events, not all include notifications, making it easy to overlook some. Having a single home for all these events would definitely help me stay on top of things.

To implement this, I created a POST endpoint in the service that accepts multiple events. When it comes to a **concurrent writes** to the database, several alternative solutions exist:

* **PostgreSQL**, which utilizes Multiversion Concurrency Control (MVCC).
    
* **MongoDB**, which employs MVCC and internal locking mechanisms.
    
* **Firestore**, which implements optimistic concurrency.
    

A simpler alternative when using Room is to use a **Mutex**. If data loss during unexpected service termination is acceptable, periodically or conditionally (e.g., when a ConcurrentHashMap reaches a specific size) persisting data from a **ConcurrentHashMap** to the Room database is another viable option.

The GET endpoint retrieves all events from the database, splits them into old and new based on the current time, deletes old events in a thread-safe way, and responds with the list of new (upcoming) events.

```kotlin
get {
     try {
         val now = Clock.System.now()
         val events = eventDao.getEvents().map { it.toEventDto() }
         val oldEvents = events.filter { it.date < now }
          val newEvents = events.filter { it.date >= now }

          // Delete old events from the database in a batch operation
          if (oldEvents.isNotEmpty()) {
                eventMutex.withLock {
                    oldEvents.map { it.id }.forEach { id ->
                        eventDao.deleteEvent(id)
                    }
               }
          }

          call.respond(newEvents)
     } catch (e: Exception) {
          call.respond(HttpStatusCode.InternalServerError, "Failed to retrieve events: ${e.message}")
     }
  }
```

We also use eventMutex.withLock for `post("/single")` and `post("/multiple")` POST events endpoints to ensure thread-safe operations when writing to the database.

```kotlin
post("/multiple") {
    try {
        val newEvents = call.receive<List<EventDto>>()
        eventMutex.withLock {
            eventDao.upsertEvents(newEvents.map { it.toEventEntity() })
        }
        call.respond(HttpStatusCode.Created)
    } catch (e: IllegalStateException) {
        call.respond(HttpStatusCode.BadRequest, "Invalid event data: ${e.message}")
    } catch (e: JsonConvertException) {
        call.respond(HttpStatusCode.BadRequest, "Invalid JSON format: ${e.message}")
    } catch (e: Exception) {
        call.respond(HttpStatusCode.InternalServerError, "Failed to save events: ${e.message}")
    }
}
```

---

### Test POST with Postman

Let’s test the POST by generating a few sample events in Postman.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399358267/b856329d-dec9-4b62-8b3c-ff6246ccc99f.png align="center")

I listed events below the weather forecast, but you can create a separate dashboard just for the events.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766399378789/4ab7be9b-306c-4987-b07e-dc42d7fa19dc.png align="center")

---

## Upload Images

There are at least four ways of receiving images in Ktor. There are pros and cons of each method, but I decided to use **receiveMultipart()**, which is scalable since it allows iterating through the individual parts of the requests, and process each part separately if needed.

I also tried using **receiveChannel()**, which is simpler, but highly efficient way of receiving larger images. This approach uses Kotlin coroutines and channels for asynchronous and non-blocking I/O

`receive<ByteArray>()` - Simple to use. Loads entire ByteArray in memory. Best for smaller images.

`receiveStream()` – More efficient than receive. Best for medium size images.

`receiveChannel()` – Most efficient. Requires a coroutine scope. Best for large images.

`receiveMultipart()` – Best for complex form submissions involving files.

We can get a MultiPartData with the `call.receiveMultipart()` and then iterate over each part and processes it if it's a PartData.FileItem type. Each part is disposed of after processing to free resources.

```kotlin
private fun io.ktor.server.routing.Route.setupFilesRoutes() {
        route("/files") {
            post("/upload/photo") {
                try {
                    val multipart = call.receiveMultipart()
                    multipart.forEachPart { part ->
                        if (part is PartData.FileItem) {
                            handlePhotoUpload(part)
                        }
                        part.dispose() // Ensure all parts are disposed
                    }
                    call.respond(HttpStatusCode.OK, "File uploaded")
                } catch (e: Exception) {
                    call.respond(HttpStatusCode.InternalServerError, "Failed to upload file: ${e.message}")
                }
            }
        }
    }
```

In the Postman, select "Body" and "form-data". Then select am image file you want to send and add short description. Next, send the image.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766403902396/9bf87a9b-87be-4a34-b00a-2faf610e424c.png align="center")

You can preview the uploaded image in the device, by going to Device Explorer -&gt; Data -&gt; Data -&gt; com.yourapp.name -&gt; files

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766404355934/ecc304cf-51b1-4f3c-9e66-653e4275cb39.png align="center")

---

## Websockets

For scenarios requiring immediate reception or broadcast of updated data upon change, WebSockets offer an effective solution. This is particularly beneficial when connecting a sensor to a Raspberry Pi and needing to push value changes to the client, either upon any change or conditionally to reflect specific alterations.

For the sake of example, I'll demonstrate a chat application between two or more users instead of broadcasting sensor values.

```kotlin
webSocket("/chat") {
    val username = call.request.queryParameters["username"] ?: run {
        this.close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "No username"))
        return@webSocket
    }

    users[username] = this
    send(Frame.Text("You are connected! There are ${users.size} users here."))

    try {
        incoming.consumeEach { frame: Frame ->
            if (frame is Frame.Text) {
                try {
                    val msg = Json.decodeFromString<MessageDto>(frame.readText())
                    if (msg.to.isNullOrBlank()) {
                        users.values.forEach { usr ->
                            usr.send(Frame.Text("[$username]: ${msg.text}"))
                        }
                    } else {
                        users[msg.to]?.send(Frame.Text("[$username]: ${msg.text}"))
                    }
                } catch (e: Exception) {
                    send(Frame.Text("Error processing message: ${e.message}"))
                }
            }
        }
    } finally {
        users.remove(username)
        this.close()
    }
}
```

In the Postman select New -&gt; WebSocket, and enter "**ws://ip\_address/end\_point username=your\_name**" in the address bar. Then click "Connect".

In the body, enter the message you want to send to the user, and click "Send".

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766404580772/234fd6e7-16fe-498e-bd2f-e958c04a8171.png align="center")

Sending a message to a user.

You can also send a message to a group chat.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766404881314/3ab29d45-52f6-4310-892c-01d8dbffbfe1.png align="center")

---

### Send an image to a group chat

I found broadcasting images to be a more engaging than sending messages. To test this approach in Postman, an image must be converted to **Base64** binary encoding. Subsequently, the message type in Postman should be set to Binary/Base64 before sending the message. It is crucial to avoid sending excessively large images, as this can result in a substantial binary message that may be discarded during transmission.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766404998948/723c27a4-b181-4676-a62d-c7f3c052202c.png align="center")

Sending an image in binary base64 format

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766405033720/604636aa-da91-413a-b7d9-c0cd67ded1f6.png align="center")

Receive an image via Websockets

---

## Hosting a simple website

If you want to host a simple website, you can do this by creating a resources (if not existing already) and adding a subfolder for html, js and css files. You can access the site at `http://[your_server_IP_address]:8080/`.

```kotlin
get("/") {
     val indexFile = this.javaClass.classLoader?.getResource("serveradmin/dist/index.html")
     if (indexFile != null) {
         call.respondText(indexFile.readText(), ContentType.Text.Html)
     } else {
         val altFile = File(filesDir, "serveradmin/dist/index.html")
         if (altFile.exists()) {
             call.respondText(altFile.readText(), ContentType.Text.Html)
         } else {
             call.respond(HttpStatusCode.NotFound, "index.html not found")
         }
     }
 }
 staticResources("/", "serveradmin/dist") {
     default("index.html")
 }
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766405098819/2b6b4ec4-0761-479b-b572-738b20bf3fb4.png align="center")

Create a resources folder with a subfolder for HTML, JS, and CSS files

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766405191318/6bd271d0-99ef-49a0-bec9-95112372c41a.png align="center")

---

## Problems

1. **Limited hardware support -** many hardware components attached to the Raspberry Pi are not supported by Android OS. For example, I couldn't get the camera to work.
    
2. **Hard to debug** - The Android Studio recognised the device couple of times only.The wireless debugging works sporadically. I had to restart the debugging multiple times to make it work.This is despite enabling both Wireless Debugging and ADB over Ethernet in Amteria settings. I had to develop using an emulator, and then spend significant amount of time trying to deploy to the Raspberry Pi
    
3. ![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766405223684/f104f69e-c16e-4e6d-847e-e880686eb054.png align="center")
    

---

## Reading data from Sensors

I tired using the [Pi4J](https://github.com/Pi4J/pi4j) library to read temperature, humidity, and pressure from a BME280 sensor, but I was unsuccessful. I’ve found that Python libraries are much more user-friendly for reading data from sensors connected to a Raspberry Pi.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1766447355213/9e0adabe-52cd-4803-ad5d-5a6ada99f161.webp align="center")

## Final thoughts

Implementing Android OS with a touchscreen on a Raspberry Pi allows Android developers to leverage powerful client-side dependencies, reduce context switching, and promote code sharing and logic reuse. This setup provides continuous operation without battery concerns and enables microservices implementation, making it cost-effective and versatile for various applications.

By integrating both a Ktor service and UI within the same Android application, developers can achieve seamless service and user interface integration, enhancing efficiency and functionality.

A class service code can be found [here](https://gist.github.com/pitoszud/55dd33eabdc1d384e304fd84aebaef32).

## Next steps

Since I faced issues with deployment and limited hardware and sensor support, I plan to create a similar service using Svelte and FastAPI. This should let me fully utilise Python libraries and connect sensors to the Raspberry Pi.
