Android, Raspberry Pi i Ktor — poszerzanie możliwości
Interfejs na Androida (Jetpack Compose) i backend w Ktorze na tym samym Raspberry Pi — usługi pierwszoplanowe, lokalny CRUD, websockety, przesyłanie zdjęć i to, co stanęło na przeszkodzie.
Opis projektu
Celem tego eksperymentu było sprawdzenie architektury full-stack, w której interfejs na Androida (Jetpack Compose) i serwer backendowy (Ktor) działają w jednym projekcie na Raspberry Pi.
W czasie, gdy nad tym pracowałem, Kotlin Multiplatform (KMP) był jeszcze na wczesnym, niestabilnym etapie, więc szukałem innych sposobów na współdzielenie kodu i logiki między klientem a serwerem na dedykowanym urządzeniu.
Zalety uruchamiania całego stosu na Raspberry Pi
- Cały stos w jednym projekcie
- Prostsze wdrażanie — nie trzeba przełączać się między IDE ani zarządzać osobnymi projektami.
- Współdzielenie kodu — podobnie jak w KMP mogłem współdzielić klasy danych i część logiki biznesowej między frontendem a serwerem.
- Wspólna warstwa danych — używałem Rooma zarówno w interfejsie, jak i na serwerze, razem z Mutexem, co dobrze działało w prostych scenariuszach.
- Dostępność i praktyczne zastosowania
- Brak baterii litowo-jonowej — projekt działał całą dobę z zawsze włączonym ekranem, więc nie było ryzyka przegrzania ani degradacji baterii.
- REST API — zastosowań REST API w sieci lokalnej jest sporo, ale moje ulubione to wysyłanie przypomnień lub notatek z telefonu i wyświetlanie ich jako banerów informacyjnych.
- Interaktywny pulpit — mój ulubiony pulpit to prognoza pogody z przypomnieniami i wydarzeniami z kalendarza. Ponieważ używam 7,5-calowego ekranu dotykowego, można przełączać się między różnymi pulpitami.
Którą wersję wybrać?
Są co najmniej dwa sposoby na zainstalowanie Androida na Raspberry Pi.
- Konstakang udostępnia wersje AOSP i LineageOS z dodatkowymi funkcjami i usprawnieniami.
- Emteria oferuje Emteria.OS — komercyjne rozwiązanie, darmowe do użytku prywatnego.
W sieci jest mnóstwo poradników opisujących instalację każdej z opcji, więc nie będę się tu nad tym rozwodził. Zdecydowałem się na Emteria.OS, który na Raspberry Pi okazał się stabilniejszy niż wersje oparte na LineageOS. Jeśli jednak planujesz podłączać czujniki, kamery lub inne peryferia, wersje od Konstakanga mogą być lepszym wyborem.
W jednej aplikacji na Androida na Raspberry Pi można uruchomić zarówno usługę Ktor, jak i interfejs. Przy zadaniach administracyjnych, takich jak aktualizacje bazy danych czy utrzymanie usługi Ktor, warto jednak stworzyć osobne warianty buildu „admin” i „user”. Dzięki temu funkcje administracyjne trafiają do osobnej aplikacji, a odpowiedzialności są czyściej rozdzielone.
productFlavors {
create("server") {
isDefault = true
dimension = "environment"
buildConfigField("String", "ENVIRONMENT", "\"server\"")
}
create("admin") {
dimension = "environment"
buildConfigField("String", "ENVIRONMENT", "\"admin\"")
}
}
Zużycie energii
Raspberry Pi z 7,5-calowym ekranem, działające bez przerwy, zużywa zwykle od 6,0 W do 6,5 W. W zależności od uruchomionych procesów mogą się zdarzać chwilowe skoki poboru mocy.

Przygotowanie przed instalacją
Stay awake
Żeby ekran nie przechodził w tryb uśpienia, włącz ustawienie „Stay Awake”. Nie jest to konieczne, ale dzięki temu interfejs jest stale widoczny, a w tej aplikacji właśnie o to chodzi.

Przypisanie adresu IP
Nowoczesne routery zwykle pamiętają powiązania adresów IP z adresami MAC i po ponownym połączeniu przydzielają urządzeniu ten sam adres. Starsze routery mogą jednak nadać Raspberry Pi nowy adres, jeśli urządzenie było offline, a dzierżawa DHCP wygasła. Żeby temu zapobiec, ręcznie przypisz stały adres IP do adresu MAC Pi.

Wyłącz „pause app activity”
Usługa pierwszoplanowa sama w sobie ogranicza wstrzymywanie, ale wyłączenie opcji „Pause app activity if unused” daje dodatkowy margines niezawodności.

Włącz powiadomienia
Usługa może działać z wyłączonymi powiadomieniami, ale ich włączenie daje cenny wgląd w to, jakie usługi aktualnie działają.

Dodaj uprawnienia w manifeście
Ponieważ aplikacja łączy się z internetem i uruchamia usługę pierwszoplanową z powiadomieniami, trzeba dodać do manifestu kilka uprawnień.

Dodaj usługę pierwszoplanową w manifeście
Zdefiniuj usługę pierwszoplanową typu dataSync, przeznaczoną do przesyłania danych, np. wysyłania, pobierania, odczytu danych czy lokalnego przetwarzania plików.

Włącz niezabezpieczone połączenia
To tylko testy w sieci lokalnej, więc można tu pominąć konfigurację certyfikatów. Utwórz plik network_security_config.xml w folderze xml projektu Androida i podaj adres IP Raspberry Pi, żeby na potrzeby developmentu zezwolić na niezabezpieczone połączenia.

Pamiętaj, że w prawdziwym środowisku produkcyjnym zawsze należy włączyć bezpieczne połączenia i skonfigurować odpowiednie certyfikaty. Pokazana tu konfiguracja służy wyłącznie do testów i eksperymentów.
Następnie dodaj ten plik w manifeście jako networkSecurityConfig.

Utwórz usługę pierwszoplanową
Usługa pierwszoplanowa z mechanizmem restartu zapewnia, że serwer działa bez przerwy. Kolejność ma znaczenie:
- Utwórz kanał powiadomień.
- Zbuduj powiadomienie.
- Uruchom usługę pierwszoplanową.
Jeśli system zabije usługę, zostanie ona odtworzona, a onStartCommand() zostanie wywołane z intentem równym null.
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 usługi pierwszoplanowej
Gdy system restartuje usługę, która zwróciła START_STICKY, ponownie wywołuje onCreate(), a potem onStartCommand(). W tym ponownym wywołaniu parametr intent jest równy null. createNotificationChannel() i startForegroundService() przywracają wtedy usługę jako pierwszoplanową razem z jej stałym powiadomieniem.
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("ServerService", "restarting server service")
startForegroundService()
return START_STICKY
}
Uruchom usługę pierwszoplanową
Najprościej uruchomić usługę z głównej aktywności w onCreate().
private fun runRestfulService() {
val intent = Intent(this, ServerService::class.java)
startService(intent)
}
W tym momencie powinno się pojawić powiadomienie od usługi pierwszoplanowej informujące, że serwer działa.

Agregowanie i wyświetlanie danych
Agregowanie i wyświetlanie danych z API przy jednocześnie działającej usłudze sprawdza się w tej konfiguracji bez zarzutu — to jedna z głównych zalet równoległego działania interfejsu na Androida i usługi Ktor.

Lokalne operacje CRUD
W moich kalendarzach Google i Outlook jest mnóstwo wydarzeń, ale nie wszystkie mają powiadomienia, więc łatwo coś przeoczyć. Jedno miejsce na wszystkie te wydarzenia pomaga mi nad wszystkim panować.
Żeby to zrealizować, stworzyłem w usłudze endpoint POST przyjmujący wiele wydarzeń naraz. Do współbieżnych zapisów w bazie danych jest kilka alternatyw:
- PostgreSQL, który używa wielowersyjnej kontroli współbieżności (MVCC).
- MongoDB, który stosuje MVCC i wewnętrzne mechanizmy blokad.
- Firestore, który stosuje optymistyczną kontrolę współbieżności.
Prostszą alternatywą przy Roomie jest Mutex. Jeśli utrata danych przy nieoczekiwanym zatrzymaniu usługi jest akceptowalna, inną opcją jest okresowe lub warunkowe (np. gdy ConcurrentHashMap osiągnie określony rozmiar) zapisywanie danych z ConcurrentHashMap do bazy Room.
Endpoint GET pobiera wszystkie wydarzenia z bazy, dzieli je na stare i nowe względem bieżącego czasu, bezpiecznie wątkowo usuwa stare i zwraca listę nowych (nadchodzących) wydarzeń.
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}")
}
}
eventMutex.withLock jest też używany w endpointach post("/single") i post("/multiple"), żeby zapisy do bazy były bezpieczne wątkowo.
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 w Postmanie
Endpoint POST przetestowałem, generując w Postmanie kilka przykładowych wydarzeń, a następnie wyświetliłem je pod prognozą pogody na pulpicie — równie dobrze można by zbudować dla nich osobny pulpit.


Przesyłanie zdjęć
W Ktorze są co najmniej cztery sposoby odbierania obrazów, każdy z innymi kompromisami:
receive<ByteArray>()— prosty w użyciu, wczytuje całą tablicę bajtów do pamięci. Najlepszy dla mniejszych obrazów.receiveStream()— wydajniejszy niżreceive. Najlepszy dla obrazów średniej wielkości.receiveChannel()— najwydajniejszy, wymaga zakresu korutyn. Najlepszy dla dużych obrazów. Wykorzystuje korutyny i kanały Kotlina do asynchronicznego, nieblokującego I/O.receiveMultipart()— najlepszy do złożonych formularzy z plikami.
Zdecydowałem się na receiveMultipart(), który dobrze się skaluje, bo pozwala przechodzić przez poszczególne części żądania i w razie potrzeby przetwarzać każdą osobno. Z call.receiveMultipart() dostajesz MultiPartData, a potem iterujesz po częściach i przetwarzasz te, które są PartData.FileItem. Po przetworzeniu każda część jest zwalniana, żeby oddać zasoby.
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}")
}
}
}
}
Żeby przetestować to w Postmanie: wybierz „Body” → „form-data”, wskaż plik obrazu, dodaj krótki opis i wyślij. Przesłany obraz możesz podejrzeć na urządzeniu przez Device Explorer → Data → Data → com.yourapp.name → files.


Websockety
Gdy dane trzeba odbierać lub rozgłaszać natychmiast, WebSockety sprawdzają się doskonale — szczególnie gdy do Raspberry Pi podłączony jest czujnik, a zmiany wartości trzeba wypychać do klienta, przy każdej zmianie albo warunkowo, tylko przy określonych zmianach.
Jako przykład zamiast rozgłaszania wartości z czujnika — mały czat dla dwóch lub więcej użytkowników:
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()
}
}
W Postmanie: wybierz New → WebSocket, wpisz w pasku adresu ws://ip_address/end_point?username=your_name i kliknij Connect. Wpisz wiadomość w treści i kliknij Send, żeby napisać do konkretnego użytkownika albo do całego czatu grupowego.


Wysyłanie obrazu na czat grupowy
Rozsyłanie obrazów okazało się ciekawsze niż wysyłanie wiadomości tekstowych. Żeby przetestować to w Postmanie, obraz trzeba zakodować w Base64 i przed wysłaniem ustawić typ wiadomości na Binary/Base64. Warto unikać zbyt dużych obrazów, bo powstaje wtedy duża wiadomość binarna, która może zostać odrzucona podczas przesyłania.


Hostowanie prostej strony
Żeby hostować prostą stronę, utwórz folder resources (jeśli go nie ma) z podfolderem na pliki HTML, JS i CSS. Strona będzie wtedy dostępna pod adresem http://[your_server_IP_address]:8080/.
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")
}


Problemy
- Ograniczona obsługa sprzętu — wiele podzespołów podłączanych do Raspberry Pi nie jest obsługiwanych przez Androida. Na przykład nie udało mi się uruchomić kamery.
- Trudne debugowanie — Android Studio rozpoznało urządzenie tylko kilka razy, a debugowanie bezprzewodowe działało sporadycznie, mimo włączenia w ustawieniach Emterii zarówno Wireless Debugging, jak i ADB over Ethernet. Musiałem pracować na emulatorze, a potem poświęcić sporo czasu na uruchomienie wdrożenia na Raspberry Pi.

Odczyt danych z czujników
Próbowałem odczytywać temperaturę, wilgotność i ciśnienie z czujnika BME280 za pomocą biblioteki Pi4J, ale bez powodzenia. Przekonałem się, że biblioteki w Pythonie są dużo wygodniejsze do odczytu danych z czujników podłączonych do Raspberry Pi.

Podsumowanie
Android z ekranem dotykowym na Raspberry Pi pozwala programistom Androida korzystać z rozbudowanych bibliotek po stronie klienta, rzadziej przełączać kontekst oraz współdzielić kod i logikę. Taka konfiguracja działa bez przerwy, bez zmartwień o baterię, i umożliwia budowanie mikrousług, co czyni ją tanią i wszechstronną w wielu zastosowaniach.
Łącząc usługę Ktor i interfejs w jednej aplikacji na Androida, dostajesz spójną integrację usługi z interfejsem w jednej bazie kodu. Przykładowy kod klasy usługi znajdziesz w tym gist.
Kolejne kroki
Ponieważ natrafiłem na problemy z wdrażaniem oraz ograniczoną obsługą sprzętu i czujników, planuję zbudować podobną usługę w Svelte i FastAPI — co powinno pozwolić mi w pełni korzystać z pythonowych bibliotek do czujników na Raspberry Pi.