Skip to content

Instantly share code, notes, and snippets.

@pitoszud
Created December 22, 2025 12:45
Show Gist options
  • Select an option

  • Save pitoszud/55dd33eabdc1d384e304fd84aebaef32 to your computer and use it in GitHub Desktop.

Select an option

Save pitoszud/55dd33eabdc1d384e304fd84aebaef32 to your computer and use it in GitHub Desktop.
Ktor Service on Raspberry Pi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.graphics.scale
import com.apurebase.kgraphql.GraphQL
import com.velocip.local.home.service.R
import com.velocip.ybs.data.utils.toEventDto
import com.velocip.ybs.data.utils.toEventEntity
import com.velocip.ybs.database.dao.EventDao
import com.velocip.ybs.database.entity.EventEntity
import com.velocip.ybs.network.dto.events.EventDto
import com.velocip.ybs.network.dto.message.MessageDto
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.PartData
import io.ktor.http.content.forEachPart
import io.ktor.http.content.streamProvider
import io.ktor.serialization.JsonConvertException
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.http.content.staticFiles
import io.ktor.server.http.content.staticResources
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receive
import io.ktor.server.request.receiveMultipart
import io.ktor.server.request.receiveNullable
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.io.File
import java.io.FileOutputStream
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
private const val MAX_FRAME_SIZE = 10 * 1024 * 1024 // Max frame size is 10MB
private const val THUMBNAIL_WIDTH = 100
private const val THUMBNAIL_QUALITY = 85
@AndroidEntryPoint
class ServerService: Service() {
@Inject
lateinit var eventDao: EventDao
@Inject
@ApplicationContext
lateinit var context: Context
@Inject
lateinit var graphQLSchema: com.apurebase.kgraphql.schema.Schema
private val eventMutex = Mutex()
private val json = Json {
serializersModule = SerializersModule {
contextual(Instant.serializer())
}
ignoreUnknownKeys = true
}
private var server: NettyApplicationEngine? = null
private val connections = Collections.synchronizedSet<DefaultWebSocketSession>(LinkedHashSet())
private val users = ConcurrentHashMap<String, WebSocketSession>()
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForegroundService()
setupServer()
}
private fun setupServer() {
server = embeddedServer(Netty, port = 8080) {
install(ContentNegotiation) { json(json) }
install(WebSockets) {
pingPeriod = 15.seconds.toJavaDuration()
timeout = 30.seconds.toJavaDuration()
maxFrameSize = MAX_FRAME_SIZE.toLong()
}
install(GraphQL) { // For schema definition and playground
playground = true
schema {
configure {
useDefaultPrettyPrinter = true
}
type<EventDto> {
description = "Event DTO"
}
query("events") {
description = "Get all events"
resolver { ->
runBlocking {
eventDao.getEvents().map { it.toEventDto() }
}
}
}
}
}
routing {
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")
}
setupFilesRoutes()
setupEventsRoutes()
val webAppDir = File(context.filesDir, "serveradmin/dist")
if (webAppDir.exists() && webAppDir.isDirectory) {
staticFiles("/", webAppDir)
}
}
}
server?.start(wait = false)
}
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}")
}
}
}
}
private suspend fun handlePhotoUpload(part: PartData.FileItem) = withContext(Dispatchers.IO) {
// Save original file to disk
val bytes = part.streamProvider().readBytes()
val fileName = part.originalFileName?.takeIf {
it.matches(Regex("[a-zA-Z0-9_.-]+"))
} ?: "unknown_${System.currentTimeMillis()}"
val file = File(context.filesDir, fileName)
file.writeBytes(bytes)
// Create thumbnail and save to disk
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
val aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat()
val height = (THUMBNAIL_WIDTH / aspectRatio).toInt()
val thumbnail = bitmap.scale(THUMBNAIL_WIDTH, height)
val thumbnailFile = File(context.filesDir, "thumbnail_$fileName")
val fileExtension = fileName.substringAfterLast('.', "").lowercase()
FileOutputStream(thumbnailFile).use { out ->
when (fileExtension) {
"png" -> thumbnail.compress(Bitmap.CompressFormat.PNG, THUMBNAIL_QUALITY, out)
"jpg", "jpeg" -> thumbnail.compress(Bitmap.CompressFormat.JPEG, THUMBNAIL_QUALITY, out)
else -> throw IllegalArgumentException("Unsupported file type: $fileExtension")
}
}
// Recycle bitmaps to free memory
thumbnail.recycle()
bitmap.recycle()
}
private fun io.ktor.server.routing.Route.setupEventsRoutes() {
route("/events") {
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}")
}
}
get("/byId/{id}") {
try {
val id = call.parameters["id"] ?: ""
if (id.isEmpty()) {
call.respond(HttpStatusCode.BadRequest, "Event ID is required")
return@get
}
val event = eventDao.getEvent(id)?.toEventDto()
if (event == null) {
call.respond(HttpStatusCode.NotFound, "Event not found")
return@get
}
call.respond(event)
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, "Failed to retrieve event: ${e.message}")
}
}
post("/single") {
try {
val event = call.receiveNullable<EventDto>() ?: run {
call.respond(HttpStatusCode.BadRequest, "Invalid event data")
return@post
}
eventMutex.withLock {
eventDao.upsertEvent(event.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 event: ${e.message}")
}
}
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}")
}
}
delete("/{id}") {
try {
val id = call.parameters["id"] ?: ""
if (id.isEmpty()) {
call.respond(HttpStatusCode.BadRequest, "Event ID is required")
return@delete
}
var eventEntity: EventEntity? = null
eventMutex.withLock {
eventEntity = eventDao.deleteEventAndReturn(id)
}
if (eventEntity == null) {
call.respond(HttpStatusCode.NotFound, "Event not found")
return@delete
}
call.respond(HttpStatusCode.NoContent)
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, "Failed to delete event: ${e.message}")
}
}
setupWebSockets()
}
}
private fun io.ktor.server.routing.Route.setupWebSockets() {
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()
}
}
webSocket("/photoBroadcast") {
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.Binary) {
val photoBytes = frame.readBytes()
// Limit broadcast to 10MB
if (photoBytes.size <= MAX_FRAME_SIZE) {
users.values.forEach { user ->
user.send(Frame.Binary(true, photoBytes))
}
} else {
send(Frame.Text("Error: Photo exceeds maximum size limit"))
}
}
}
} finally {
users.remove(username)
this.close()
}
}
}
override fun onDestroy() {
server?.stop(1000, 2000)
runBlocking {
connections.forEach {
try {
it.close(CloseReason(CloseReason.Codes.GOING_AWAY, "Server shutting down"))
} catch (e: Exception) {
// Ignore exceptions during shutdown
}
}
}
connections.clear()
users.clear()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.d("ServerService", "Server service destroyed")
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = 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())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("ServerService", "restarting server service")
createNotificationChannel()
startForegroundService()
return START_STICKY
}
companion object {
const val CHANNEL_ID = "ServerServiceChannel"
const val NOTIFICATION_ID = 1
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment