Coverage Summary for Class: AppServer (release.com.greybox.projectmesh.server)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| AppServer$1 |
0%
(0/2)
|
|
| AppServer$1$invokeSuspend$lambda$4$lambda$3$$inlined$sortedByDescending$1 |
0%
(0/1)
|
|
| AppServer$acceptIncomingTransfer$totalTransferred$1$1$1 |
0%
(0/1)
|
|
| AppServer$addOutgoingTransfer$1 |
0%
(0/1)
|
0%
(0/3)
|
0%
(0/15)
|
0%
(0/159)
|
| AppServer$Companion |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/1)
|
| AppServer$handleMyInfoRequest$$inlined$instance$1 |
0%
(0/1)
|
|
| AppServer$handleMyInfoRequest$localUser$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/29)
|
| AppServer$IncomingTransferInfo |
0%
(0/2)
|
0%
(0/18)
|
0%
(0/8)
|
0%
(0/324)
|
| AppServer$IncomingTransferInfo$$serializer |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/2)
|
| AppServer$IncomingTransferInfo$Companion |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/4)
|
| AppServer$markAllUsersOffline$1 |
0%
(0/1)
|
0%
(0/5)
|
0%
(0/9)
|
0%
(0/79)
|
| AppServer$onDeclineIncomingTransfer$1 |
|
| AppServer$onDeclineIncomingTransfer$2 |
0%
(0/1)
|
0%
(0/3)
|
0%
(0/8)
|
0%
(0/102)
|
| AppServer$onDeleteIncomingTransfer$2 |
0%
(0/1)
|
|
| AppServer$onDeleteIncomingTransfer$2$2 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/25)
|
| AppServer$OutgoingTransferInfo |
0%
(0/1)
|
|
0%
(0/8)
|
0%
(0/58)
|
| AppServer$pushUserInfoTo$1 |
0%
(0/1)
|
|
| AppServer$pushUserInfoTo$1$invokeSuspend$$inlined$instance$1 |
0%
(0/1)
|
|
| AppServer$pushUserInfoTo$1$localUser$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/29)
|
| AppServer$requestRemoteUserInfo$1 |
0%
(0/1)
|
|
| AppServer$requestRemoteUserInfo$1$invokeSuspend$$inlined$instance$1 |
0%
(0/1)
|
|
| AppServer$requestRemoteUserInfo$1$user$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/33)
|
| AppServer$sendChatMessageWithStatus$1 |
|
| AppServer$sendDeviceName$2 |
0%
(0/1)
|
0%
(0/7)
|
0%
(0/14)
|
0%
(0/139)
|
| AppServer$sendDeviceName$2$1 |
0%
(0/1)
|
0%
(0/6)
|
0%
(0/12)
|
0%
(0/91)
|
| AppServer$serve$$inlined$instance$1 |
0%
(0/1)
|
|
| AppServer$serve$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/36)
|
| AppServer$serve$2 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/36)
|
| AppServer$serve$3 |
0%
(0/1)
|
|
| AppServer$serve$6 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/3)
|
0%
(0/43)
|
| AppServer$serve$conversation$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/4)
|
0%
(0/30)
|
| AppServer$serve$user$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/1)
|
0%
(0/29)
|
| AppServer$serve$userName$1 |
0%
(0/1)
|
0%
(0/6)
|
0%
(0/3)
|
0%
(0/39)
|
| AppServer$Status |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/36)
|
| AppServer$updateUserConnectionStatus$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/7)
|
0%
(0/63)
|
| AppServer$updateUserOnlineStatus$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/7)
|
0%
(0/63)
|
| Total |
0%
(0/64)
|
0%
(0/70)
|
0%
(0/112)
|
0%
(0/1450)
|
package com.greybox.projectmesh.server
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.se.omapi.Session
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.gson.Gson
import com.greybox.projectmesh.DeviceStatusManager
import com.greybox.projectmesh.GlobalApp
import com.greybox.projectmesh.MainActivity
import com.greybox.projectmesh.R
import com.greybox.projectmesh.db.MeshDatabase
import com.greybox.projectmesh.messaging.data.entities.Message
import com.greybox.projectmesh.messaging.network.MessageNetworkHandler
import com.greybox.projectmesh.extension.updateItem
import com.greybox.projectmesh.testing.TestDeviceService
import com.ustadmobile.meshrabiya.ext.copyToWithProgressCallback
import com.ustadmobile.meshrabiya.util.FileSerializer
import com.ustadmobile.meshrabiya.util.InetAddressSerializer
import fi.iki.elonen.NanoHTTPD
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.headersContentLength
import java.io.Closeable
import java.io.File
import java.io.FileOutputStream
import java.net.InetAddress
import java.net.URLEncoder
import java.util.concurrent.atomic.AtomicInteger
import com.greybox.projectmesh.extension.getUriNameAndSize
import com.greybox.projectmesh.messaging.data.entities.JSONSchema
import com.greybox.projectmesh.user.UserEntity
import com.greybox.projectmesh.user.UserRepository
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.instance
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import java.net.URLDecoder
import kotlinx.coroutines.runBlocking
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
//import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.net.URI
import okhttp3.RequestBody.Companion.toRequestBody
import com.greybox.projectmesh.util.NotificationHelper
import com.ustadmobile.meshrabiya.log.MNetLogger
/*
This File is the Server for transferring files
The Meshrabiya test app uses NanoHttpD as the server, OkHttp as the client
*/
class AppServer(
private val appContext: Context,
private val httpClient: OkHttpClient, // OkHttp client for making HTTP requests
private val mLogger: MNetLogger,
name: String,
port: Int = 0, // Port for NanoHTTPD server, default is 0
val localVirtualAddr: InetAddress,
private val receiveDir: File, // Directory for receiving files
private val json: Json,
private val db: MeshDatabase,
override val di: DI,
private val userRepository: UserRepository
) : NanoHTTPD(port), Closeable, DIAware {
private val logPrefix: String = "[AppServer - $name] "
private val scope = CoroutineScope(Dispatchers.IO + Job())
enum class Status {
PENDING, IN_PROGRESS, COMPLETED, FAILED, DECLINED
}
private fun handleMyInfoRequest(): Response {
val sharedPrefs: SharedPreferences by di.instance(tag = "settings")
val localUuid = sharedPrefs.getString("UUID", null) ?: return newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "application/json", """{"error":"No local UUID found"}"""
)
val localUser = runBlocking { userRepository.getUser(localUuid) } ?: return newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "application/json", """{"error":"No local user record in DB"}"""
)
// Add the local IP to the address field so it comes through in JSON
val localUserWithAddr = localUser.copy(
address = localVirtualAddr.hostAddress // or any IP you want to send
)
val userJson = json.encodeToString(UserEntity.serializer(), localUserWithAddr)
return newFixedLengthResponse(Response.Status.OK, "application/json", userJson)
}
// Restart method to stop and start the server with an optional new IP address
fun restart() {
stop() // Stop the server using NanoHTTPD's built-in stop method
Log.d("AppServer", "Server stopped successfully")
start(SOCKET_READ_TIMEOUT, false) // Start the server using NanoHTTPD's built-in start method
Log.d("AppServer", "Server restarted successfully on port: $localPort")
}
//change to json
/*
This data class contains all the information about the outgoing transfer (Sending a file)
Why not using Serializable annotation?
-> Considering the use case of Outgoing Transfer, we only upload a file, and sending this file.
-> The file will be temporary stored in the local memory.
*/
data class OutgoingTransferInfo(
val id: Int, // Transfer ID
val name: String, // Name of the file being sent
val uri: Uri, // URI of the file to be sent
val toHost: InetAddress, // Destination address of the transfer
val status: Status = Status.PENDING, // Initial transfer status is PENDING
val size: Int, // Size of the file in bytes
val transferred: Int = 0, // Bytes transferred so far
)
/*
This data class contains all the information about the incoming transfer (Receiving a file)
By using Serializable annotation, The IncomingTransferInfo object will be serialized to JSON file,
this will:
1. persist the state of transfer, even if the app is closed or crashed
2. be necessary for sending and receiving structured data over network
Why there are two parameters (fromHost, file) use custom serializers?
-> These are complex types, Kotlin's serialization framework doesn't know how to serialize them.
-> The custom serializers are from Meshrabiya library.
*/
@Serializable
data class IncomingTransferInfo(
val id: Int,
val requestReceivedTime: Long = System.currentTimeMillis(),
@Serializable(with = InetAddressSerializer::class)
val fromHost: InetAddress, // Address of the sender
val deviceName: String = "Unknown Device",
val name: String, // Name of the file being received
val status: Status = Status.PENDING,
val size: Int, // size of the file in byte
val transferred: Int = 0, // Bytes transferred so far
val transferTime: Int = 1, // Time taken to transfer the file in ms
@Serializable(with = FileSerializer::class)
val file: File? = null,
)
// Atomic Integer is a thread-safe integer, using incrementAndGet() method to
// generate unique transfer ID without worrying about race conditions
private val transferIdAtomic = AtomicInteger()
// This is a MutableStateFlow holding a list of OutgoingTransferInfo object, It used to hold
// and manage a list of ongoing file transfers.
private val _outgoingTransfers = MutableStateFlow(emptyList<OutgoingTransferInfo>())
// This is the public accessible Flow, used to observe the list of ongoing file transfers
val outgoingTransfers: Flow<List<OutgoingTransferInfo>> = _outgoingTransfers.asStateFlow()
// similar to the previous one, it holds a list of IncomingTransferInfo object.
private val _incomingTransfers = MutableStateFlow(emptyList<IncomingTransferInfo>())
val incomingTransfers: Flow<List<IncomingTransferInfo>> = _incomingTransfers.asStateFlow()
// This method (getListeningPort) is provided by the NanoHTTPD to get the port number which the
// server is listening.
val localPort: Int
get() = super.getListeningPort()
/*
Initialization Block:
Fetches all qualified files and combined with previous list of files, then sorted by
requestReceivedTime in descending order.
*/
init {
scope.launch {
// fetch all files in receiveDir directory that end with .rx.json extension
val incomingFiles = receiveDir.listFiles { _ , fileName: String? ->
fileName?.endsWith(".rx.json") == true
}?.map {
// It deserializes the JSON file into an IncomingTransferInfo object
json.decodeFromString(IncomingTransferInfo.serializer(), it.readText())
// if no files match the criteria, return an empty list
} ?: emptyList()
NotificationHelper.createNotificationChannel(appContext)
/*
updates the _incomingTransfers MutableStateFlow with the list of incoming files
It combines the previous list with newly read files from the directory
All the files are sorted by requestReceivedTime in descending order, which means
the latest request will be listed first
*/
_incomingTransfers.update { prev ->
buildList {
addAll(prev)
addAll(incomingFiles.sortedByDescending { it.requestReceivedTime })
}
}
}
}
/*
This is a crucial function that implement an HTTP Server
*/
override fun serve(session: IHTTPSession): Response {
// Extracts the URI from the session, which is the path of the request
val path = session.uri
mLogger(Log.INFO, "$logPrefix : ${session.method} ${session.uri}")
//Create instance of the JSON Schema
val JSONschema = JSONSchema()
if (path.startsWith("/ping")) {
//Simple endpoint to check if server is responsive
return newFixedLengthResponse("PONG")
}
// check if the path is for download, indicating the request wants to download a file
// 1) /myinfo route
if (path.startsWith("/myinfo")) {
return handleMyInfoRequest()
}
else if (path.startsWith("/updateUserInfo")) {
// Read the POST body (assumed JSON)
val postData = session.inputStream.bufferedReader().readText()
//Validates JSON payload
if(!JSONschema.schemaValidation(postData)){
return newFixedLengthResponse(
Response.Status.BAD_REQUEST, "application/json", """{"error":"Invalid JSON schema"}"""
)
}
try {
// Decode the JSON payload into a UserEntity
val updatedUser = json.decodeFromString(UserEntity.serializer(), postData)
// Update or insert the user info in the database
runBlocking {
userRepository.insertOrUpdateUser(updatedUser.uuid, updatedUser.name, updatedUser.address)
}
return newFixedLengthResponse(
Response.Status.OK, "application/json", """{"status":"OK"}"""
)
} catch (e: Exception) {
e.printStackTrace()
return newFixedLengthResponse(
Response.Status.BAD_REQUEST, "application/json", """{"error":"Invalid user data"}"""
)
}
}
else if (path.startsWith("/updateUserInfo")) {
// Read the POST body (assumed JSON)
val postData = session.inputStream.bufferedReader().readText()
try {
// Decode the JSON payload into a UserEntity
val updatedUser = json.decodeFromString(UserEntity.serializer(), postData)
// Update or insert the user info in the database
runBlocking {
userRepository.insertOrUpdateUser(updatedUser.uuid, updatedUser.name, updatedUser.address)
}
return newFixedLengthResponse(
Response.Status.OK, "application/json", """{"status":"OK"}"""
)
} catch (e: Exception) {
e.printStackTrace()
return newFixedLengthResponse(
Response.Status.BAD_REQUEST, "application/json", """{"error":"Invalid user data"}"""
)
}
}
// 2) /download/
else if (path.startsWith("/download/")) {
// Extracts the transfer ID (Integer)from the path by taking the last part of the path
val xferId = path.substringAfterLast("/").toInt()
// Find the outgoing transfer with the given xferId
val outgoingXfer = _outgoingTransfers.value.first {
it.id == xferId
}
// This tracks how many bytes have been read from the stream
val contentIn = appContext.contentResolver.openInputStream(outgoingXfer.uri)?.let {
InputStreamCounter(it.buffered())
}
// If the input stream could not be opened, the function returns an HTTP response
// with a status of INTERNAL_ERROR and an error message
if(contentIn == null) {
mLogger(Log.ERROR, "$logPrefix Failed to open input stream to serve $path - ${outgoingXfer.uri}")
return newFixedLengthResponse(
Response.Status.INTERNAL_ERROR, "text/plain",
"Failed to open InputStream")
}
mLogger(Log.INFO, "$logPrefix Sending file for xfer #$xferId")
// Preparing the file for download
val response = newFixedLengthResponse(
Response.Status.OK, "application/octet-stream",
contentIn,
outgoingXfer.size.toLong()
)
//Provide status updates by checking how many bytes have been read periodically
/*
The loop runs until the stream (contentIn) is closed. Every 500 milliseconds,
the code updates the _outgoingTransfers list by checking how many bytes have been read
so far (contentIn.bytesRead).
(.updateItem): This is a custom extension function from List
*/
scope.launch {
while(!contentIn.closed) {
_outgoingTransfers.update{ prev ->
prev.updateItem(
condition = { it.id == xferId },
function = { item ->
item.copy(
transferred = contentIn.bytesRead,
)
}
)
}
delay(500)
}
/*
After the file transfer completes, it checks if the number of bytes read equals
the file size. If they match, the status is set to COMPLETED,
Otherwise, it's set to FAILED.
*/
val status = if(contentIn.bytesRead == outgoingXfer.size) {
Status.COMPLETED
}else {
Status.FAILED
}
mLogger(Log.INFO, "$logPrefix Sending file for xfer #$xferId")
/*
Updating _outgoingTransfers again to set the final transferred bytes and status
(COMPLETED or FAILED).
*/
_outgoingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == xferId },
function = { item ->
item.copy(
transferred = contentIn.bytesRead,
status = status
)
}
)
}
}
// Returns the prepared response to initiate the file download.
return response
}
// Check if it is a sending request
else if(path.startsWith("/send")) {
mLogger(Log.INFO, "$logPrefix Received incoming transfer request")
// Parse the query parameters from the URL, converting them to a key-value map
val searchParams = session.queryParameterString.split("&")
.map {
it.substringBefore("=") to it.substringAfter("=")
}.toMap()
// Extract the values for "id", "filename", "size", and "from" from the query parameters
val id = searchParams["id"]
val filename = URLDecoder.decode(searchParams["filename"], "UTF-8")
// if size is missing or invalid, then defaults to -1
val size = searchParams["size"]?.toInt() ?: -1
val fromAddr = searchParams["from"]
// if everything is ready, create a new incomingTransferInfo object that contains all the
// info extract from the query parameters
if(id != null && filename != null && fromAddr != null) {
val ipStr = fromAddr
val userName = runBlocking {
val user = userRepository.getUserByIp(ipStr)
user?.name ?: "Unknown Device"
}
val incomingTransfer = IncomingTransferInfo(
id = id.toInt(),
fromHost = InetAddress.getByName(fromAddr),
name = filename,
size = size,
deviceName = userName
)
//find if theres an existing convo from sender
val user = runBlocking { userRepository.getUserByIp(ipStr) }
var routeToChatEnabled = false
var chatConversationId: String? = null
if (user != null ) {
val localUuid = GlobalApp.GlobalUserRepo.prefs.getString("UUID", null) ?: "local-user"
try {
val conversation = runBlocking {
GlobalApp.GlobalUserRepo.conversationRepository.getOrCreateConversation(
localUuid = localUuid,
remoteUser = user
)
}
routeToChatEnabled = true
chatConversationId = conversation.id
} catch (e: Exception) {
Log.e("AppServer", "Failed to find conversation for file sender", e)
}
}
//update the _incomingTransfers list with new incoming transers
//then the new list is added to the top
_incomingTransfers.update { prev ->
buildList {
add(incomingTransfer)
addAll(prev)
}
}
//show notification based on whether we have a conversation from the sender
if (routeToChatEnabled && chatConversationId != null) {
showFileInChatNotification(incomingTransfer, chatConversationId)
} else {
// Fall back to the regular file notification
NotificationHelper.showFileReceivedNotification(appContext, filename)
mLogger(Log.INFO, "$logPrefix Added request id $id for $filename from ${incomingTransfer.fromHost}")
}
return newFixedLengthResponse("OK")
}else {
mLogger(Log.INFO, "$logPrefix incomin transfer request - bad request - missing params")
// Return an error response if any of the required parameters are missing
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Bad request")
}
}
// Handle decline request
else if(path.startsWith("/decline")){
// get the transfer id from the last part of path
// For example: path is "/decline/123", then we get 123 and converts to int
val xferId = path.substringAfterLast("/").toInt()
// Update _outgoingTransfers list, setting the specific item status to DECLINED
_outgoingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == xferId },
function = {
it.copy(
status = Status.DECLINED
)
}
)
}
// return "OK", indicating the decline request has been handled
return newFixedLengthResponse("OK")
}
else if(path.startsWith("/getDeviceName")){
Log.d("AppServer", "local ip address: ${localVirtualAddr.hostAddress}")
val settingPref: SharedPreferences by di.instance(tag="settings")
return newFixedLengthResponse(settingPref.getString("device_name", Build.MODEL) ?: Build.MODEL)
}
else if(path.startsWith("/chat")) {
//Log.d("AppServer", "Received chat message*******")
//Processes JSON payload into useable data
val files = mutableMapOf<String, String>()
session.parseBody(files)
val jsonPayload = files["postData"] // This is where the full body lives
//Log.d("AppServer", "JSON Payload******: $jsonPayload")
//Checks if payload is null/blank
if (jsonPayload.isNullOrBlank()) {
Log.e("AppServer", "Empty or missing JSON payload")
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Empty or missing JSON payload")
}else{
Log.d("AppServer", "JSON payload not blank")
}
//Validates validity of JSON payload
val JSONschema = JSONSchema()
Log.d("AppServer", "Validating JSON payload: ${jsonPayload::class.simpleName}")
if (!JSONschema.schemaValidation(jsonPayload)) {
Log.e("AppServer", "Invalid JSON payload")
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "application/json", """{"error":"Invalid JSON schema"}""")
}else{
Log.d("AppServer", "Valid JSON payload")
}
//Deserialize the JSON payload
val deserialzedJSON = json.decodeFromString<Message>(jsonPayload)
val chatMessage = deserialzedJSON.content ?: null
val time = deserialzedJSON.dateReceived ?: System.currentTimeMillis()
val senderIpStr = deserialzedJSON.sender ?: null
Log.d("AppServer", "Received chat message: '$chatMessage' from $senderIpStr at $time")
// val chatMessage = session.parameters["chatMessage"]?.firstOrNull()
// val timeParam = session.parameters["time"]?.firstOrNull()
// val time = timeParam?.toLongOrNull() ?: System.currentTimeMillis()
// val senderIpStr = session.parameters["senderIp"]?.firstOrNull()
if (senderIpStr == null) {
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Missing senderIp parameter")
}
val senderIp = try {
InetAddress.getByName(senderIpStr)
} catch (e: Exception) {
Log.e("AppServer", "Invalid sender IP address: $senderIpStr", e)
return newFixedLengthResponse(Response.Status.BAD_REQUEST, "text/plain", "Invalid sender IP")
}
// Handle optional file parameter
//val fileUriStr = session.parameters["incomingfile"]?.firstOrNull()
val fileUriStr = deserialzedJSON.file?.toString()
val incomingfile = if (fileUriStr != null) {
try {
URI.create(fileUriStr)
} catch (e: Exception) {
Log.e("AppServer", "Invalid file URI: $fileUriStr", e)
null
}
} else {
null
}
Log.d("AppServer", "Received chat message: '$chatMessage' from $senderIpStr")
try {
val message = MessageNetworkHandler.handleIncomingMessage(
chatMessage,
time,
senderIp,
incomingfile
)
scope.launch {
db.messageDao().addMessage(message)
Log.d("AppServer", "Message saved to database: $message")
}
// Change response type to ensure it's properly formatted
return newFixedLengthResponse(Response.Status.OK, "text/plain", "OK")
} catch (e: Exception) {
Log.e("AppServer", "Error processing chat message", e)
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "text/plain", "Error processing message")
}
}
else {
mLogger(Log.INFO, "$logPrefix : $path - NOT FOUND")
// Returns a NOT_FOUND response indicating that the requested path could not be found.
return newFixedLengthResponse(
Response.Status.NOT_FOUND, "text/plain", "not found: $path"
)
}
}
suspend fun sendDeviceName(wifiAddress: InetAddress, port: Int = DEFAULT_PORT){
scope.launch {
try {
val ipStr = wifiAddress.hostAddress
Log.d("AppServer", "wifiAddress: $ipStr")
// GET /getDeviceName
val uri = "http://$ipStr:$port/getDeviceName"
val request = Request.Builder().url(uri).build()
Log.d("AppServer", "Request: $request")
val response = httpClient.newCall(request).execute()
Log.d("AppServer", "Response: $response")
// The remote device's name
val remoteDeviceName = response.body?.string()
Log.d("AppServer", "Remote device name: $remoteDeviceName")
response.close() // best practice: close the response
if (!remoteDeviceName.isNullOrEmpty()) {
runBlocking {
val existingUser = userRepository.getUserByIp(ipStr)
if (existingUser == null) {
// No user? Insert with a temporary or random UUID
val pseudoUuid = "temp-$ipStr"
userRepository.insertOrUpdateUser(
uuid = pseudoUuid,
name = remoteDeviceName,
address = ipStr
)
} else {
// Already have a user? Update the name
userRepository.insertOrUpdateUser(
uuid = existingUser.uuid,
name = remoteDeviceName,
address = existingUser.address
)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.e("AppServer", "Failed to get device name from ${wifiAddress.hostAddress}")
}
}
}
/*fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {//old
scope.launch {
try {
val url = "http://${remoteAddr.hostAddress}:$port/myinfo"
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
val userJson = response.body?.string()
response.close()
if (!userJson.isNullOrEmpty()) {
// Decode JSON into a UserEntity
val remoteUser = json.decodeFromString(UserEntity.serializer(), userJson)
// Insert or update in DB
userRepository.insertOrUpdateUser(remoteUser.uuid, remoteUser.name)
// Possibly store lastSeen, remote IP, etc., if your entity includes those fields
}
} catch (e: Exception) {
Log.e("AppServer", "Failed to fetch /myinfo from $remoteAddr", e)
}
}
}*/
/**
* Add an outgoing transfer. This is done using a Uri so that we don't have to make our own
* copy of the file the user wants to transfer.
*/
// Add a new function to show notifications that route to the chat screen
private fun showFileInChatNotification(transfer: IncomingTransferInfo, conversationId: String) {
try {
val title = "File Received"
val content = "Tap to view ${transfer.name} in chat"
val intent = Intent(appContext, MainActivity::class.java).apply {
action = "OPEN_CHAT_CONVERSATION"
putExtra("conversationId", conversationId)
putExtra("from_notification", true)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
appContext, 1005, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val channelId = "file_receive_channel"
val notification = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_VIBRATE or NotificationCompat.DEFAULT_SOUND)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(1005, notification)
Log.d("AppServer", "Showed notification for file in chat")
} catch (e: Exception) {
Log.e("AppServer", "Failed to show file in chat notification", e)
// Fall back to regular notification
NotificationHelper.showFileReceivedNotification(appContext, transfer.name)
}
}
fun addOutgoingTransfer(//change to json
uri: Uri, // The uri of the file to be transferred
toNode: InetAddress, // The recipient's IP address
toPort: Int = DEFAULT_PORT, // The recipient's port number
): OutgoingTransferInfo {
// generate a unique transfer ID
val transferId = transferIdAtomic.incrementAndGet()
// Get the name and size of the file or content using
// customized extension function of ContentResolver
val nameAndSize = appContext.contentResolver.getUriNameAndSize(uri)
val validName = nameAndSize.name ?: "unknown"
mLogger(Log.INFO, "$logPrefix adding outgoing transfer of $uri " +
"(name=${nameAndSize.name} size=${nameAndSize.size} to $toNode:$toPort")
// create an OutgoingTransferInfo object with all transfer information
val outgoingTransfer = OutgoingTransferInfo(
id = transferId,
name = validName,
uri = uri ,
toHost = toNode,
size = nameAndSize.size.toInt(),
)
scope.launch(Dispatchers.IO){
try{
val gs = Gson()
val json = gs.toJson(outgoingTransfer)
val bodtype = "application/json; charset=utf-8".toMediaTypeOrNull()
val bod = json.toRequestBody(bodtype)
val request = Request.Builder().url("http://${toNode.hostAddress}:$toPort/" +
"send?id=$transferId&filename=${URLEncoder.encode(validName, "UTF-8")}" +
"&size=${nameAndSize.size}&from=${localVirtualAddr.hostAddress}")
.post(bod)
.build()//changed this to send a post request
mLogger(Log.INFO, "$logPrefix notifying $toNode of incoming transfer")
val resp = httpClient.newCall(request).execute()
val serverResponse = resp.body?.string()
mLogger(Log.INFO, "$logPrefix - received response: $serverResponse")
} catch(e: Exception){
mLogger(Log.ERROR, "$logPrefix - exception: $e")
}
}
_outgoingTransfers.update { prev ->
buildList {
add(outgoingTransfer)
addAll(prev)
}
}
return outgoingTransfer
}
fun removeOutgoingTransfer(transferId: Int) {
_outgoingTransfers.update { prev ->
prev.filter { it.id != transferId }
}
}
/*
Accept an incoming transfer
*/
fun acceptIncomingTransfer(
transfer: IncomingTransferInfo,
destFile: File,
fromPort: Int = DEFAULT_PORT,
) {
// record the current time
val startTime = System.currentTimeMillis()
// Update the _incomingTransfers list, setting the status to IN_PROGRESS
_incomingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == transfer.id },
function = { item -> item.copy(
status = Status.IN_PROGRESS,
)
}
)
}
try {
// Build the request to download the file from the sender
val request = Request.Builder()
.url("http://${transfer.fromHost.hostAddress}:$fromPort/download/${transfer.id}")
.build()
// sending the request using OkHttp3
val response = httpClient.newCall(request).execute()
// Get the size of the file that will be downloaded from response
val fileSize = response.headersContentLength()
// 0L -> L indicates type Long
var lastUpdateTime = 0L
/*
Download file,Writes it to destFile, and reports progress every 500 ms
*/
val totalTransferred = response.body?.byteStream()?.use { responseIn ->
FileOutputStream(destFile).use { fileOut ->
responseIn.copyToWithProgressCallback(
out = fileOut,
onProgress = { bytesTransferred ->
val timeNow = System.currentTimeMillis()
if(timeNow - lastUpdateTime > 500) {
_incomingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == transfer.id },
function = { item ->
item.copy(
transferred = bytesTransferred.toInt()
)
}
)
}
lastUpdateTime = System.currentTimeMillis()
}
}
)
}
}
response.close()
// calculate the total time taken for downloading the file
val transferDurationMs = (System.currentTimeMillis() - startTime).toInt()
// Update the status of the transfer based on whether the entire file was
// successfully downloaded or not
val incomingTransfersVal = _incomingTransfers.updateAndGet { prev ->
prev.updateItem(
condition = { it.id == transfer.id },
function = { item ->
item.copy(
transferTime = transferDurationMs,
status = if(totalTransferred == fileSize) {
Status.COMPLETED
}else {
Status.FAILED
},
file = destFile,
transferred = totalTransferred?.toInt() ?: item.transferred
)
}
)
}
//Write JSON to file so received files can be listed after app restarts etc.
val incomingTransfer = incomingTransfersVal.firstOrNull {
it.id == transfer.id
}
// Create a JSON file and write the serialized transfer info to the file
if(incomingTransfer != null) {
val jsonFile = File(receiveDir, "${incomingTransfer.name}.rx.json")
jsonFile.writeText(json.encodeToString(IncomingTransferInfo.serializer(), incomingTransfer))
}
val speedKBS = transfer.size / transferDurationMs
mLogger(Log.INFO, "$logPrefix acceptIncomingTransfer successful: Downloaded " +
"${transfer.size}bytes in ${transfer.transferTime}ms ($speedKBS) KB/s")
}
catch(e: Exception) {
mLogger(Log.ERROR, "$logPrefix acceptIncomingTransfer ($transfer) FAILED", e)
_incomingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == transfer.id },
function = { item -> item.copy(
transferred = destFile.length().toInt(),
status = Status.FAILED,
)
}
)
}
}
}
/**
* Checks if a device is reachable at the application level
* This performs a lightweight check without doing a full user info exchange
*/
fun checkDeviceReachable(remoteAddr: InetAddress, port: Int = DEFAULT_PORT): Boolean {
try {
val url = "http://${remoteAddr.hostAddress}:$port/ping"
val request = Request.Builder().url(url).build()
httpClient.newCall(request).execute().use { response ->
return response.isSuccessful
}
} catch (e: Exception) {
Log.e("AppServer", "Failed to check if device ${remoteAddr.hostAddress} is reachable", e)
return false
}
}
/*
Decline an incoming transfer
It sends an HTTP request to notify the sender that the file transfer has been
declined and updates the status of the transfer to DECLINED in the local list
of incoming transfers.
*/
suspend fun onDeclineIncomingTransfer(
transfer: IncomingTransferInfo,
fromPort: Int = DEFAULT_PORT,
) {
/*
This block ensures that the network request is performed on the IO thread pool,
which is optimized for input/output operations like networking and file handling.
*/
withContext(Dispatchers.IO) {
// Construct an HTTP request to decline the incoming transfer(Using OkHTTP3)
val request = Request.Builder()
.url("http://${transfer.fromHost.hostAddress}:$fromPort/decline/${transfer.id}")
.build()
try {
// Send the request to the sender and get the response
val response = httpClient.newCall(request).execute()
val strResponse = response.body?.string()
mLogger(Log.DEBUG, "$logPrefix - onDeclineIncomingTransfer - request to: ${request.url} : response = $strResponse")
}catch(e: Exception) {
mLogger(Log.WARN, "$logPrefix - onDeclineIncomingTransfer : exception- request to: ${request.url} : FAIL", e)
}
}
// update the _incomingTransfers list, setting the status to DECLINED
_incomingTransfers.update { prev ->
prev.updateItem(
condition = { it.id == transfer.id },
function = {
it.copy(
status = Status.DECLINED,
)
}
)
}
}
/*
Delete an incoming transfer
It removes both file associated with the transfer and the metadata (stored in a JSON file)
from the device, then updates the internal state to remove the transfer from the list of
incoming transfers.
*/
suspend fun onDeleteIncomingTransfer(
incomingTransfer: IncomingTransferInfo
) {
/*
Since file operations can be slow and block the main thread,
it is important to run them on the IO thread pool,
which is optimized for disk and network I/O tasks.
*/
withContext(Dispatchers.IO) {
// It will create a File object pointing to the .rx.json file
// that stores metadata about the transfer.
val jsonFile = incomingTransfer.file?.let {
File(it.parentFile, it.name + ".rx.json")
}
// Delete both the file and the JSON file associated with the transfer
incomingTransfer.file?.delete()
jsonFile?.delete()
// Update the _incomingTransfers list, removing the transfer that was just deleted
_incomingTransfers.update { prev ->
prev.filter { it.id != incomingTransfer.id }
}
// Ensure Jetpack Compose recomposes UI before handling another delete
withContext(Dispatchers.Main) {
delay(150)
}
}
}
/**
* Send a chat message and return delivery status
*/
suspend fun sendChatMessageWithStatus(address: InetAddress, time: Long, message: String, f: URI?): Boolean {
try {
if (TestDeviceService.isTestDevice(address)) {
// Create an echo response from our test device
val testMessage = Message(
id = 0,
dateReceived = System.currentTimeMillis(),
content = "Echo: $message",
sender = TestDeviceService.TEST_DEVICE_NAME,
chat = TestDeviceService.TEST_DEVICE_NAME,
file = f //dont send file with echo messages
)
// Store the echo response in our database
db.messageDao().addMessage(testMessage)
Log.d("AppServer", "Test device echoed message: $message")
return true
}
// Original code for real devices
val httpUrl = HttpUrl.Builder()
.scheme("http")
.host(address.hostAddress)
.port(DEFAULT_PORT)
.addPathSegment("chat")
.addQueryParameter("chatMessage", message)
.addQueryParameter("time", time.toString())
.addQueryParameter("senderIp", localVirtualAddr.hostAddress)
.build()
Log.d("AppServer", "Request URL: $httpUrl")
val gs = Gson()
val msg = Message(//test this
id = 0,
dateReceived = time,
sender = localVirtualAddr.hostName,
chat = address.hostAddress,
content = message,
file = null//made the file null so that the file doesn't send
)
Log.d("AppServer", "Messagefile: ${msg.file.toString()}")
val msgJson = gs.toJson(msg)
//modified http
val httpURL = HttpUrl.Builder() .scheme("http") .host(address.hostAddress) .port(DEFAULT_PORT) .addPathSegment("chat").build()
//Log.d("AppServer", "HTTP URL: $httpURL")
//post request body
val mt = "application/json; charset=utf-8".toMediaType()
val rbody = msgJson.toRequestBody(mt)
//Log.d("Appserver", "HTTP URL: $httpUrl")
val request = Request.Builder()
.url(httpUrl)
.post(rbody)
.addHeader("Content-Type", "application/json")
.build()
// Execute the request with proper error handling
try {
httpClient.newCall(request).execute().use { response ->
val successful = response.isSuccessful
if (successful) {
Log.d("AppServer", "Message successfully sent to ${address.hostAddress}")
} else {
Log.d("AppServer", "Failed to send message to ${address.hostAddress}, status code: ${response.code}")
}
return successful
}
} catch (e: Exception) {
Log.e("AppServer", "Failed to send message to ${address.hostAddress}: ${e.message}", e)
return false
}
} catch (e: Exception) {
e.printStackTrace()
Log.d("AppServer", "Failed to send message to ${address.hostAddress}: ${e.message}")
return false
}
}
/*
fun sendChatMessage(address: InetAddress, time: Long, message: String, f: URI?) {//need to test this
scope.launch {//check to see if this accommodates for json
//not sending URI, i don't think the json is working
try {
Log.d("AppServer", "chat message: $message")
if (TestDeviceService.isTestDevice(address)) {
// Create an echo response from our test device
val testMessage = Message(
id = 0,
dateReceived = System.currentTimeMillis(),
content = "Echo: $message",
sender = TestDeviceService.TEST_DEVICE_NAME,
chat = TestDeviceService.TEST_DEVICE_NAME,
file = f//ok this is working
)
// Store the echo response in our database
db.messageDao().addMessage(testMessage)
Log.d("AppServer", "Test device echoed message: $message")
return@launch
}
//original code for real devices
val httpUrl = HttpUrl.Builder()
.scheme("http")
.host(address.hostAddress)
.port(DEFAULT_PORT)
.addPathSegment("chat")
.addQueryParameter("chatMessage", message)
.addQueryParameter("time", time.toString())
.addQueryParameter("senderIp", localVirtualAddr.hostAddress)
.build()
Log.d("AppServer", "Request URL: $httpUrl")
val request = Request.Builder()
.url(httpUrl)
.build()
val response = httpClient.newCall(request).execute()
Log.d("AppServer", "Response: ${response.code}")
}
catch (e: Exception) {
e.printStackTrace()
Log.d("AppServer", "Failed to send message to ${address.hostAddress}")
}
}
}
*/
fun pushUserInfoTo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
scope.launch {
// Retrieve your local user info (assume you store your UUID in SharedPreferences)
val sharedPrefs: SharedPreferences by di.instance(tag = "settings")
val localUuid = sharedPrefs.getString("UUID", null)
if (localUuid == null) {
Log.e("AppServer", "Local UUID not found, cannot push user info")
return@launch
}
val localUser = runBlocking { userRepository.getUser(localUuid) }
if (localUser == null) {
Log.e("AppServer", "Local user info not found in DB, cannot push user info")
return@launch
}
// Convert the user info to JSON
val userJson = json.encodeToString(UserEntity.serializer(), localUser)
val url = "http://${remoteAddr.hostAddress}:$port/updateUserInfo"
val requestBody = userJson.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
Log.d("AppServer", "Pushing user info to $url with payload: $userJson")
try {
val response = httpClient.newCall(request).execute()
// Log the response code and body (if any)
val responseBody = response.body?.string()
Log.d("AppServer", "Response from ${remoteAddr.hostAddress}: Code=${response.code}, Body=$responseBody")
} catch (e: Exception) {
Log.e("AppServer", "Failed to push user info to ${remoteAddr.hostAddress}", e)
}
}
}
fun requestRemoteUserInfo(remoteAddr: InetAddress, port: Int = DEFAULT_PORT) {
//Special handling for test devices
val ipAddress = remoteAddr.hostAddress
// Online test device should always be "online"
if (ipAddress == TestDeviceService.TEST_DEVICE_IP) {
DeviceStatusManager.updateDeviceStatus(ipAddress, true)
return
}
// Offline test device should always be "offline"
if (ipAddress == TestDeviceService.TEST_DEVICE_IP_OFFLINE) {
DeviceStatusManager.updateDeviceStatus(ipAddress, false)
return
}
scope.launch {
try {
val url = "http://${remoteAddr.hostAddress}:$port/myinfo"
val request = Request.Builder().url(url).build()
Log.d("AppServer", "Requesting remote user info from $url")
val response = httpClient.newCall(request).execute()
val userJson = response.body?.string()
Log.d("AppServer", "Received user info from ${remoteAddr.hostAddress}: $userJson")
response.close()
if (!userJson.isNullOrEmpty()) {
// 1) Decode JSON
val remoteUser = json.decodeFromString(UserEntity.serializer(), userJson)
// 2) Optionally override with the *actual* IP of the request
// if you prefer forcing the discovered IP over the user’s self-reported IP.
val remoteUserWithIp = remoteUser.copy(
address = remoteAddr.hostAddress
)
// 3) Insert or update that IP in the DB
userRepository.insertOrUpdateUser(
remoteUserWithIp.uuid,
remoteUserWithIp.name,
remoteUserWithIp.address
)
// 4) update user connection status to online
updateUserOnlineStatus(
userUuid = remoteUserWithIp.uuid,
isOnline = true,
userAddress = remoteUserWithIp.address
)
// 5) update device status manager to show device online
DeviceStatusManager.updateDeviceStatus(remoteAddr.hostAddress, true)
//try to create a conversation
try {
//get local UUID
val sharedPrefs: SharedPreferences by di.instance(tag = "settings")
val localUuid = sharedPrefs.getString("UUID", null)
if ( localUuid != null){
//create convo
GlobalApp.GlobalUserRepo.conversationRepository.getOrCreateConversation(
localUuid = localUuid,
remoteUser = remoteUserWithIp
)
Log.d("AppServer", "Created conversation with ${remoteUserWithIp.name}")
}
}catch (e: Exception) {
Log.e("AppServer", "Failed to create conversation", e)
//Update Device Status Manager to show device is offline when connection fails
DeviceStatusManager.updateDeviceStatus(remoteAddr.hostAddress, false)
//update convo status for this user
val user = runBlocking { userRepository.getUserByIp(remoteAddr.hostAddress) }
if (user != null) {
updateUserOnlineStatus(
userUuid = user.uuid,
isOnline = false,
userAddress = null
)
}
}
Log.d("AppServer", "Updated local DB with remote user info: $remoteUserWithIp")
}
} catch (e: Exception) {
Log.e("AppServer", "Failed to fetch /myinfo from ${remoteAddr.hostAddress}", e)
}
}
}
fun updateUserConnectionStatus(userUuid: String, isOnline: Boolean, userAddress: String?) {
scope.launch {
try {
GlobalApp.GlobalUserRepo.conversationRepository.updateUserStatus(
userUuid = userUuid,
isOnline = isOnline,
userAddress = userAddress
)
Log.d("AppServer", "Updated user $userUuid connection status: online=$isOnline, address=$userAddress")
}catch (e: Exception){
Log.e("AppServer", "Failed to update user connection status", e)
}
}
}
fun updateUserOnlineStatus(userUuid: String, isOnline: Boolean, userAddress: String?){
scope.launch {
try {
GlobalApp.GlobalUserRepo.conversationRepository.updateUserStatus(
userUuid = userUuid,
isOnline = isOnline,
userAddress = userAddress
)
Log.d("AppServer", "Updated user $userUuid connection status: online=$isOnline, address=$userAddress")
} catch (e: Exception){
Log.e("AppServer", "Failed to update user connection status", e)
}
}
}
fun markAllUsersOffline(){
scope.launch {
try {
val allUsers = GlobalApp.GlobalUserRepo.userRepository.getAllConnectedUsers()
for (user in allUsers) {
GlobalApp.GlobalUserRepo.conversationRepository.updateUserStatus(
userUuid = user.uuid,
isOnline = false,
userAddress = null
)
}
Log.d("AppServer", "Marked all users as offline")
} catch (e: Exception) {
Log.e("AppServer", "Failed to mark all users as offline", e)
}
}
}
// Stop the server and cancel any coroutine that are running within the CoroutineScope
override fun close() {
stop()
scope.cancel()
}
companion object {
const val DEFAULT_PORT = 9614//MAIN HAD MODIFIED IT TO 9614 FROM 4242
val CHAT_TYPE_PLAINTEXT = "text/plain; charset=utf-8".toMediaType()
}
}