Coverage Summary for Class: ReceiveScreenKt (release.com.greybox.projectmesh.views)

Class Method, % Branch, % Line, % Instruction, %
ReceiveScreenKt$HandleIncomingTransfers$$inlined$instance$1 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$1 0% (0/1) 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$2 0% (0/1) 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$3 0% (0/1) 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$4 0% (0/1) 0% (0/1) 0% (0/3) 0% (0/33)
ReceiveScreenKt$HandleIncomingTransfers$5 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$1 0% (0/1) 0% (0/1) 0% (0/20)
ReceiveScreenKt$HandleIncomingTransfers$6$2$1 0% (0/1) 0% (0/1) 0% (0/5)
ReceiveScreenKt$HandleIncomingTransfers$6$2$2 0% (0/1) 0% (0/1) 0% (0/45)
ReceiveScreenKt$HandleIncomingTransfers$6$2$3 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$2$3$1$1$1 0% (0/1) 0% (0/1) 0% (0/6)
ReceiveScreenKt$HandleIncomingTransfers$6$2$3$1$1$2 0% (0/1) 0% (0/1) 0% (0/6)
ReceiveScreenKt$HandleIncomingTransfers$6$2$4 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$2$4$1$1 0% (0/1) 0% (0/1) 0% (0/6)
ReceiveScreenKt$HandleIncomingTransfers$6$2$4$1$2 0% (0/1) 0% (0/1) 0% (0/7)
ReceiveScreenKt$HandleIncomingTransfers$6$2$4$2$1 0% (0/1) 0% (0/1) 0% (0/6)
ReceiveScreenKt$HandleIncomingTransfers$6$invoke$$inlined$items$default$1 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$invoke$$inlined$items$default$2 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$invoke$$inlined$items$default$3 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$6$invoke$$inlined$items$default$4 0% (0/1)
ReceiveScreenKt$HandleIncomingTransfers$7
ReceiveScreenKt$ReceiveScreen$1 0% (0/1) 0% (0/1) 0% (0/5)
ReceiveScreenKt$ReceiveScreen$5
Total 0% (0/32) 0% (0/1) 0% (0/15) 0% (0/139)


 package com.greybox.projectmesh.views
 
 import android.content.ContentValues
 import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
 import android.net.Uri
 import android.os.Build
 import android.os.Environment
 import android.provider.DocumentsContract
 import android.provider.MediaStore
 import android.util.Log
 import android.webkit.MimeTypeMap
 import android.widget.Toast
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Cancel
 import androidx.compose.material.icons.filled.CheckCircle
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Download
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.ListItem
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.core.content.FileProvider
 import androidx.lifecycle.viewmodel.compose.viewModel
 import com.greybox.projectmesh.R
 import com.greybox.projectmesh.ViewModelFactory
 import com.greybox.projectmesh.server.AppServer
 import com.greybox.projectmesh.viewModel.ReceiveScreenViewModel
 import org.kodein.di.compose.localDI
 import org.kodein.di.DI
 import org.kodein.di.instance
 import java.io.File
 import androidx.compose.material3.HorizontalDivider
 import com.greybox.projectmesh.viewModel.ReceiveScreenModel
 
 @Composable
 fun ReceiveScreen(
     viewModel: ReceiveScreenViewModel = viewModel(
         factory = ViewModelFactory(
             di = localDI(),
             owner = LocalSavedStateRegistryOwner.current,
             vmFactory = { di, savedStateHandle -> ReceiveScreenViewModel(di, savedStateHandle) },
             defaultArgs = null,
         )
     ),
     onAutoFinishChange: (Boolean) -> Unit
 ) {
     val uiState by viewModel.uiState.collectAsState(ReceiveScreenModel())
     HandleIncomingTransfers(
         uiState = uiState,
         onAccept = viewModel::onAccept,
         onDecline = viewModel::onDecline,
         onDelete = viewModel::onDelete,
         onAutoFinishChange = onAutoFinishChange
     )
 }
 
 @Composable
 fun HandleIncomingTransfers(
     uiState: ReceiveScreenModel,
     onAccept: (AppServer.IncomingTransferInfo) -> Unit = {},
     onDecline: (AppServer.IncomingTransferInfo) -> Unit = {},
     onDelete: (AppServer.IncomingTransferInfo) -> Unit = {},
     onAutoFinishChange: (Boolean) -> Unit
 ){
     val di: DI = localDI()
     val settingPref: SharedPreferences by di.instance(tag="settings")
     val context = LocalContext.current
     var autoFinishEnabled by remember { mutableStateOf(false) }
     val defaultUri = settingPref.getString("save_to_folder", null)
         ?: "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)}/Project Mesh"
 
     LaunchedEffect(onAutoFinishChange) {
         autoFinishEnabled = settingPref.getBoolean("auto_finish", false)
         Log.d("ReceiveScreen", "autoFinishEnabled: $autoFinishEnabled")
     }
     LaunchedEffect(autoFinishEnabled, uiState.incomingTransfers) {
         if (autoFinishEnabled) {
             uiState.incomingTransfers
                 .filter { it.status == AppServer.Status.PENDING } // Only pending transfers
                 .forEach { transfer ->
                     // Automatically trigger accept action
                     onAccept(transfer)
                 }
         }
     }
 
     fun openFile(transfer: AppServer.IncomingTransferInfo){
         // get the file from the IncomingTransferInfo object
         val file = transfer.file
         // only when the file transfer completed and the file is not null
         if (file != null && transfer.status == AppServer.Status.COMPLETED){
             // Generate a content URI for the file, it provide secure access to files
             val uri = FileProvider.getUriForFile(
                 context, "com.greybox.projectmesh.fileprovider", file
             )
             // allow the system to find an app capable of handling and viewing the file
             val intent = Intent(Intent.ACTION_VIEW).apply {
                 // The flag is set to ensure the receiving app can temporarily access the file uri with read permission
                 flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
                 // determine the MIME type based on the file's extension using MimeTypeMap
                 // If the MIME type cannot be determined, a default (*/*) fallback is used
                 val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?: "*/*"
                 // Sets the URI and the MIME type on the intent so that the system can know
                 // what kind of file it is dealing with and suggest the appropriate apps.
                 setDataAndType(uri, mimeType)
             }
 
             // If an app is found, it launches the intent, allowing the user to view the file.
             // Otherwise, it shows a Toast message informing the user that File cannot be opened.
             if(intent.resolveActivity(context.packageManager) != null){
                 try{
                     context.startActivity(intent)
                 }
                 catch (e: Exception){
                     Toast.makeText(context, "Error opening file: ${e.message}", Toast.LENGTH_SHORT).show()
                 }
 
             }
             else{
                 Toast.makeText(context, "File cannot be opened", Toast.LENGTH_SHORT).show()
             }
         }
     }
     LazyColumn(modifier = Modifier.fillMaxSize()) {
         items(
             items = uiState.incomingTransfers,
             key = {"${it.fromHost.hostAddress}-${it.id}-${it.requestReceivedTime}".hashCode()}
         ){ transfer ->
             ListItem(
                 modifier = Modifier
                     .clickable {
                         openFile(transfer)
                     }
                     .fillMaxWidth(),
                 headlineContent = {
                     Text(transfer.name)
                 },
                 supportingContent = {
                     Column{
                         val fromHostAddress = transfer.fromHost.hostAddress
                         Text(stringResource(id = R.string.from) + ":")
                         Text("${transfer.deviceName}(${fromHostAddress})")
                         Text(stringResource(id = R.string.status) + ": ${transfer.status}")
                         Text(autoConvertByte(transfer.transferred) + "/" + autoConvertByte(transfer.size))
                         if(transfer.status == AppServer.Status.COMPLETED){
                             Text(stringResource(id = R.string.elapsed_time) + ": ${autoConvertMS(transfer.transferTime)}")
                         }
                         if(transfer.status == AppServer.Status.PENDING){
                             Row{
                                 IconButton(onClick = {onAccept(transfer)},
                                     modifier = Modifier.width(100.dp)) {
                                     Icon(Icons.Default.CheckCircle, contentDescription = "Accept")
                                 }
                                 Spacer(modifier = Modifier.width(8.dp))
                                 IconButton(onClick = {onDecline(transfer)},
                                     modifier = Modifier.width(100.dp)) {
                                     Icon(Icons.Default.Cancel, contentDescription = "Decline")
                                 }
                             }
                         }
                     }
                 },
                 trailingContent = {
                     if(transfer.status == AppServer.Status.COMPLETED){
                         Row (
                             verticalAlignment = Alignment.CenterVertically,
                             horizontalArrangement = Arrangement.spacedBy(8.dp) // Space between buttons
                         ){
                             IconButton(onClick = {onDelete(transfer)}) {
                                 Icon(Icons.Default.Delete, contentDescription = "Delete")
                             }
                             IconButton(onClick = {onDownload(context, transfer, defaultUri)}) {
                                 Icon(Icons.Default.Download, contentDescription = "Download")
                             }
                         }
                     }
                     else if(transfer.status == AppServer.Status.DECLINED || transfer.status == AppServer.Status.FAILED){
                         Row (
                             verticalAlignment = Alignment.CenterVertically
                         ){
                             IconButton(onClick = {onDelete(transfer)}) {
                                 Icon(Icons.Default.Delete, contentDescription = "Delete")
                             }
                         }
                     }
                 }
             )
             HorizontalDivider()
         }
     }
 }
 
 fun onDownload(context: Context, transfer: AppServer.IncomingTransferInfo, uriOrPath: String) {
     if (uriOrPath.startsWith("content://")) {
         // Handle user-selected directory via SAF
         saveFileToContentUri(context, transfer, uriOrPath)
     } else {
         // Handle default directory using File API
         saveFileToDefaultPath(context, transfer, uriOrPath)
     }
 }
 
 private fun saveFileToMediaStore(context: Context, transfer: AppServer.IncomingTransferInfo) {
     // Skip for Android 9 and below
     if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
         return
     }
 
     val resolver = context.contentResolver
     val subDirectory = "Project Mesh"
     val fullPath = "${Environment.DIRECTORY_DOWNLOADS}/$subDirectory"
     val contentValues = ContentValues().apply {
         put(MediaStore.Downloads.DISPLAY_NAME, transfer.name)
         put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
         put(MediaStore.Downloads.RELATIVE_PATH, fullPath)
     }
 
     val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
     if (uri == null) {
         Toast.makeText(context, "Failed to save file", Toast.LENGTH_LONG).show()
         return
     }
 
     try {
         resolver.openOutputStream(uri)?.use { outputStream ->
             transfer.file?.inputStream()?.use { inputStream ->
                 inputStream.copyTo(outputStream)
             }
         }
         Toast.makeText(context,
             "File saved to: $fullPath",
             Toast.LENGTH_LONG
         ).show()
     } catch (e: Exception) {
         e.printStackTrace()
         Toast.makeText(context,
             "Error saving file: ${e.localizedMessage}",
             Toast.LENGTH_LONG
         ).show()
     }
 }
 
 
 private fun saveFileToDefaultPath(context: Context, transfer: AppServer.IncomingTransferInfo, folderPath: String) {
     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
         // Android 10+ → Use MediaStore API
         saveFileToMediaStore(context, transfer)
         return
     }
     // Below implementation is for Android 9 and below
     val directoryFile = File(folderPath)
 
     // Ensure the directory exists
     if (!directoryFile.exists() && !directoryFile.mkdirs()) {
         Toast.makeText(context, "Failed to create directory: $folderPath", Toast.LENGTH_LONG).show()
         return
     }
 
     // Check if the file in `transfer` is valid
     val sourceFile = transfer.file
     if (sourceFile == null || !sourceFile.exists()) {
         Toast.makeText(context, "File not available for download", Toast.LENGTH_LONG).show()
         return
     }
 
     try {
         // Create the target file in the directory
         val targetFile = File(directoryFile, transfer.name ?: "unknown_file")
         sourceFile.copyTo(targetFile, overwrite = true)
         Toast.makeText(context, "File saved to: ${targetFile.absolutePath}", Toast.LENGTH_LONG).show()
     } catch (e: Exception) {
         e.printStackTrace()
         Toast.makeText(context, "Error saving file: ${e.localizedMessage}", Toast.LENGTH_LONG).show()
     }
 }
 
 private fun saveFileToContentUri(context: Context, transfer: AppServer.IncomingTransferInfo, uriString: String) {
     val treeUri = Uri.parse(uriString)
     val resolver = context.contentResolver
 
     // Retrieve the document ID from the tree URI
     val documentId = DocumentsContract.getTreeDocumentId(treeUri)
     val directoryUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
 
     // Check if the file in `transfer` is valid
     val sourceFile = transfer.file
     if (sourceFile == null || !sourceFile.exists()) {
         Toast.makeText(context, "File not available for download", Toast.LENGTH_LONG).show()
         return
     }
 
     try {
         // Create a new file in the selected directory
         val targetUri = DocumentsContract.createDocument(
             resolver,
             directoryUri,
             "application/octet-stream", // MIME type for binary files
             transfer.name ?: "unknown_file"
         )
 
         if (targetUri != null) {
             resolver.openOutputStream(targetUri)?.use { outputStream ->
                 sourceFile.inputStream().use { inputStream ->
                     inputStream.copyTo(outputStream) // Copy file contents
                 }
             }
             // Notify the user of the successful download
             Toast.makeText(context, "File saved to: " + targetUri.path, Toast.LENGTH_LONG).show()
         } else {
             Toast.makeText(context, "Error creating file in the selected directory", Toast.LENGTH_LONG).show()
         }
     } catch (e: Exception) {
         e.printStackTrace()
         Toast.makeText(context, "Error saving file: ${e.localizedMessage}", Toast.LENGTH_LONG).show()
     }
 }