Created
December 22, 2025 12:45
-
-
Save pitoszud/55dd33eabdc1d384e304fd84aebaef32 to your computer and use it in GitHub Desktop.
Ktor Service on Raspberry Pi
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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