From d40814ec4c415ada9e9e0243b7779ff689592e50 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 22:09:14 -0500 Subject: [PATCH] Add pause, resume, and cancel controls for uploads - Add pause/resume/cancel actions to notification bar - Add pause/resume/cancel buttons to in-app upload progress card - Track upload state (paused, uploading, cancelled) and upload type (chunked vs standard) - Chunked uploads support pause/resume, standard uploads only support cancel - Fix notification to persist cancelled state without flickering --- .../constant/AppConstants.java | 10 +- .../service/FileUploadForegroundService.kt | 276 +++++++++++++++++- .../simplelogupload/ui/screens/MainScreen.kt | 70 ++++- .../viewmodel/MainViewModel.kt | 94 ++++++ 4 files changed, 441 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java index 9013b6a..4853f37 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java +++ b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java @@ -6,10 +6,18 @@ package com.mattintech.simplelogupload.constant; public class AppConstants { public static final String APP_TAG = "SLU::"; - // Broadcast actions + // Broadcast actions (service -> UI) public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE"; public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; public static final String ACTION_UPLOAD_PROGRESS = "com.mattintech.simplelogupload.UPLOAD_PROGRESS"; + public static final String ACTION_UPLOAD_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED"; + public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED"; + public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED"; + + // Intent actions (UI/notification -> service) + public static final String ACTION_PAUSE_UPLOAD = "com.mattintech.simplelogupload.PAUSE_UPLOAD"; + public static final String ACTION_RESUME_UPLOAD = "com.mattintech.simplelogupload.RESUME_UPLOAD"; + public static final String ACTION_CANCEL_UPLOAD = "com.mattintech.simplelogupload.CANCEL_UPLOAD"; // Intent extras public static final String EXTRA_UPLOAD_CODE = "upload_code"; diff --git a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt index 01215ad..3c3821b 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt @@ -66,6 +66,15 @@ class FileUploadForegroundService : Service() { private var notificationBuilder: NotificationCompat.Builder? = null private var lastProgressUpdate: Long = 0 + // Upload state tracking + private var currentUploadManager: ChunkedUploadManager? = null + private var currentStandardUploadCall: Call? = null + private var uploadState: UploadState = UploadState.IDLE + + enum class UploadState { + IDLE, UPLOADING, PAUSED, CANCELLED + } + override fun onCreate() { super.onCreate() uploadController = UploadController(this) @@ -83,6 +92,22 @@ class FileUploadForegroundService : Service() { return START_NOT_STICKY } + // Handle control actions + when (intent.action) { + AppConstants.ACTION_PAUSE_UPLOAD -> { + handlePauseUpload() + return START_STICKY + } + AppConstants.ACTION_RESUME_UPLOAD -> { + handleResumeUpload() + return START_STICKY + } + AppConstants.ACTION_CANCEL_UPLOAD -> { + handleCancelUpload() + return START_NOT_STICKY + } + } + Log.d(TAG, "Intent received, extracting parameters...") // Get file path from intent @@ -260,6 +285,10 @@ class FileUploadForegroundService : Service() { // Create chunked upload manager directly val uploadManager = ChunkedUploadManager(this, file, maxDownloads, expiryHours) + // Store reference for pause/resume/cancel control + currentUploadManager = uploadManager + uploadState = UploadState.UPLOADING + // Set progress listener with direct notification updates uploadManager.setProgressListener(object : ChunkedUploadManager.UploadProgressListener { override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) { @@ -288,6 +317,10 @@ class FileUploadForegroundService : Service() { override fun onUploadComplete(result: UploadResult) { Log.d(TAG, "Chunked upload complete: $result") + // Clear upload reference + currentUploadManager = null + uploadState = UploadState.IDLE + // Save to history directly if successful if (result.isSuccess) { saveToHistory(result) @@ -298,17 +331,22 @@ class FileUploadForegroundService : Service() { override fun onError(error: String) { Log.e(TAG, "Chunked upload error: $error") + + // Clear upload reference + currentUploadManager = null + uploadState = UploadState.IDLE + future.complete(UploadResult(error)) } override fun onPaused() { Log.d(TAG, "Chunked upload paused") - updateNotification("Upload paused", -1, 100) + // Note: updateNotification and sendPausedBroadcast are called in handlePauseUpload() } override fun onResumed() { Log.d(TAG, "Chunked upload resumed") - updateNotification("Resuming upload...", -1, 100) + // Note: updateNotification and sendResumedBroadcast are called in handleResumeUpload() } }) @@ -372,13 +410,27 @@ class FileUploadForegroundService : Service() { // Show initial progress updateNotification("Uploading ${file.name}...", 0, 100) - client.newCall(request).enqueue(object : Callback { + // Create call and store reference for cancellation + val call = client.newCall(request) + currentStandardUploadCall = call + uploadState = UploadState.UPLOADING + + call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { Log.e(TAG, "Upload failed", e) + + // Clear upload reference + currentStandardUploadCall = null + uploadState = UploadState.IDLE + future.complete(UploadResult("Upload failed: ${e.message}")) } override fun onResponse(call: Call, response: Response) { + // Clear upload reference + currentStandardUploadCall = null + uploadState = UploadState.IDLE + response.use { if (response.isSuccessful) { try { @@ -482,6 +534,7 @@ class FileUploadForegroundService : Service() { .setOngoing(true) .setOnlyAlertOnce(true) // Don't alert on every progress update .setPriority(NotificationCompat.PRIORITY_HIGH) // High priority for better visibility + .addAction(createCancelAction()) // Add cancel action from the start .apply { // Force immediate notification display on Android 14+ (prevents system delay) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -506,18 +559,49 @@ class FileUploadForegroundService : Service() { Log.d(TAG, "Updating notification: $status (progress: $progress/$max)") + // Ignore updates if upload is cancelled (unless this IS the cancellation message) + if (uploadState == UploadState.CANCELLED && !isComplete) { + Log.d(TAG, "Ignoring notification update because upload is cancelled") + return + } + builder.setContentText(status) - if (progress >= 0) { + if (progress >= 0 && !isComplete) { // Determinate progress builder.setProgress(max, progress, false) - } else { + } else if (!isComplete) { // Indeterminate progress builder.setProgress(max, 0, true) } - // When complete, make notification dismissible and remove progress bar - if (isComplete) { + // Add action buttons based on upload state + if (!isComplete && uploadState != UploadState.CANCELLED) { + builder.clearActions() // Remove old actions + + when (uploadState) { + UploadState.PAUSED -> { + // Show Resume and Cancel buttons + builder.addAction(createResumeAction()) + builder.addAction(createCancelAction()) + } + UploadState.UPLOADING -> { + // Show Pause (if chunked) and Cancel buttons + if (currentUploadManager != null) { + builder.addAction(createPauseAction()) + } + builder.addAction(createCancelAction()) + } + else -> { + // For IDLE or other states, just show Cancel + builder.addAction(createCancelAction()) + } + } + } + + // When complete or cancelled, make notification dismissible and remove progress bar + if (isComplete || uploadState == UploadState.CANCELLED) { + builder.clearActions() // Remove all actions builder.setOngoing(false) .setProgress(0, 0, false) // Remove progress bar .setAutoCancel(true) // Allow swipe to dismiss @@ -611,4 +695,182 @@ class FileUploadForegroundService : Service() { // Send local broadcast to the ViewModel LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } + + /** + * Send a broadcast when upload is paused + */ + private fun sendPausedBroadcast() { + Log.d(TAG, "Sending paused broadcast") + val intent = Intent(AppConstants.ACTION_UPLOAD_PAUSED) + sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + /** + * Send a broadcast when upload is resumed + */ + private fun sendResumedBroadcast() { + Log.d(TAG, "Sending resumed broadcast") + val intent = Intent(AppConstants.ACTION_UPLOAD_RESUMED) + sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + /** + * Send a broadcast when upload is cancelled + */ + private fun sendCancelledBroadcast() { + Log.d(TAG, "Sending cancelled broadcast") + val intent = Intent(AppConstants.ACTION_UPLOAD_CANCELLED) + sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + /** + * Create a pause action for the notification + */ + private fun createPauseAction(): NotificationCompat.Action { + val pauseIntent = Intent(this, FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_PAUSE_UPLOAD + } + val pausePendingIntent = PendingIntent.getService( + this, + 1, + pauseIntent, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder( + android.R.drawable.ic_media_pause, + "Pause", + pausePendingIntent + ).build() + } + + /** + * Create a resume action for the notification + */ + private fun createResumeAction(): NotificationCompat.Action { + val resumeIntent = Intent(this, FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_RESUME_UPLOAD + } + val resumePendingIntent = PendingIntent.getService( + this, + 2, + resumeIntent, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder( + android.R.drawable.ic_media_play, + "Resume", + resumePendingIntent + ).build() + } + + /** + * Create a cancel action for the notification + */ + private fun createCancelAction(): NotificationCompat.Action { + val cancelIntent = Intent(this, FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_CANCEL_UPLOAD + } + val cancelPendingIntent = PendingIntent.getService( + this, + 3, + cancelIntent, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_close_clear_cancel, + "Cancel", + cancelPendingIntent + ).build() + } + + /** + * Handle pause upload request + */ + private fun handlePauseUpload() { + Log.d(TAG, "Handling pause upload request") + + if (uploadState != UploadState.UPLOADING) { + Log.w(TAG, "Cannot pause: upload not in progress (state: $uploadState)") + return + } + + uploadState = UploadState.PAUSED + + currentUploadManager?.let { manager -> + manager.pauseUpload() + updateNotification("Upload paused", -1, 100) + sendPausedBroadcast() + Log.d(TAG, "Chunked upload paused successfully") + } ?: run { + Log.w(TAG, "Cannot pause standard upload (not supported)") + updateNotification("Cannot pause standard upload", -1, 100) + } + } + + /** + * Handle resume upload request + */ + private fun handleResumeUpload() { + Log.d(TAG, "Handling resume upload request") + + if (uploadState != UploadState.PAUSED) { + Log.w(TAG, "Cannot resume: upload not paused (state: $uploadState)") + return + } + + uploadState = UploadState.UPLOADING + + currentUploadManager?.let { manager -> + manager.resumeUpload() + updateNotification("Resuming upload...", -1, 100) + sendResumedBroadcast() + Log.d(TAG, "Chunked upload resumed successfully") + } + } + + /** + * Handle cancel upload request + */ + private fun handleCancelUpload() { + Log.d(TAG, "Handling cancel upload request") + + uploadState = UploadState.CANCELLED + + // Cancel chunked upload + currentUploadManager?.let { manager -> + manager.cancelUpload() + currentUploadManager = null + Log.d(TAG, "Cancelled chunked upload") + } + + // Cancel standard upload + currentStandardUploadCall?.let { call -> + call.cancel() + currentStandardUploadCall = null + Log.d(TAG, "Cancelled standard upload") + } + + // Clear upload in progress flag + PreferencesUtil.setUploadInProgress(this, false, null) + + // Send cancelled broadcast + sendCancelledBroadcast() + + // Update notification + updateNotification("Upload cancelled", -1, 100, isComplete = true) + + // Stop service after a short delay + Handler(Looper.getMainLooper()).postDelayed({ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_DETACH) + } else { + @Suppress("DEPRECATION") + stopForeground(false) + } + stopSelf() + }, 500) + } } diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt index e8d76ea..ee2a82f 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt @@ -3,13 +3,17 @@ package com.mattintech.simplelogupload.ui.screens import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Upload import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -113,7 +117,7 @@ fun MainScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Uploading", + text = if (uiState.isPaused) "Upload Paused" else "Uploading", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer @@ -149,6 +153,70 @@ fun MainScreen( color = MaterialTheme.colorScheme.onPrimaryContainer ) } + + // Status message + uiState.uploadStatusMessage?.let { message -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontStyle = FontStyle.Italic + ) + } + + // Control buttons + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (uiState.isPaused) { + // Show Resume button + TextButton( + onClick = { viewModel.resumeUpload() } + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Resume", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Resume") + } + } else { + // Show Pause button (only for chunked uploads) + if (uiState.isChunkedUpload) { + TextButton( + onClick = { viewModel.pauseUpload() } + ) { + Icon( + Icons.Default.Pause, + contentDescription = "Pause", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Pause") + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Cancel button (always shown) + TextButton( + onClick = { viewModel.cancelUpload() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Cancel") + } + } } } } diff --git a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt index 03814fb..423b03e 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt @@ -14,6 +14,8 @@ import com.mattintech.simplelogupload.constant.AppConstants import com.mattintech.simplelogupload.controller.FileController import com.mattintech.simplelogupload.controller.UploadController import com.mattintech.simplelogupload.model.UploadResult +import com.mattintech.simplelogupload.service.FileUploadForegroundService +import com.mattintech.simplelogupload.upload.UploadStrategySelector import com.mattintech.simplelogupload.util.PreferencesUtil import com.mattintech.simplelogupload.util.ZipUtil import kotlinx.coroutines.Dispatchers @@ -30,6 +32,9 @@ import java.util.* */ data class MainUiState( val isLoading: Boolean = false, + val isPaused: Boolean = false, + val isChunkedUpload: Boolean = false, + val uploadStatusMessage: String? = null, val selectedFile: File? = null, val selectedFileName: String? = null, val dumpstateFiles: List = emptyList(), @@ -150,6 +155,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { totalBytes = 0 ) } + AppConstants.ACTION_UPLOAD_PAUSED -> { + Log.d(TAG, "Upload paused") + _uiState.value = _uiState.value.copy( + isPaused = true, + uploadStatusMessage = "Upload paused" + ) + } + AppConstants.ACTION_UPLOAD_RESUMED -> { + Log.d(TAG, "Upload resumed") + _uiState.value = _uiState.value.copy( + isPaused = false, + uploadStatusMessage = "Resuming upload..." + ) + } + AppConstants.ACTION_UPLOAD_CANCELLED -> { + Log.d(TAG, "Upload cancelled") + + // Clean up temporary zip file if it exists + currentUploadTempZipFile?.let { tempFile -> + if (tempFile.exists()) { + val deleted = tempFile.delete() + Log.d(TAG, "Cleaned up temp zip file after cancellation: ${tempFile.name}, deleted: $deleted") + } + currentUploadTempZipFile = null + } + + _uiState.value = _uiState.value.copy( + isLoading = false, + isPaused = false, + uploadProgress = 0, + bytesUploaded = 0, + totalBytes = 0, + uploadStatusMessage = "Upload cancelled" + ) + } } } } @@ -158,6 +198,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { addAction(AppConstants.ACTION_UPLOAD_PROGRESS) addAction(AppConstants.ACTION_UPLOAD_COMPLETE) addAction(AppConstants.ACTION_UPLOAD_ERROR) + addAction(AppConstants.ACTION_UPLOAD_PAUSED) + addAction(AppConstants.ACTION_UPLOAD_RESUMED) + addAction(AppConstants.ACTION_UPLOAD_CANCELLED) } LocalBroadcastManager.getInstance(getApplication()) @@ -288,8 +331,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { currentUploadMaxDownloads = maxDownloads currentUploadExpiryHours = expiryHours + // Determine if this will be a chunked upload + val isChunked = UploadStrategySelector.shouldUseChunkedUpload(getApplication(), file) + Log.d(TAG, "Upload will use ${if (isChunked) "chunked" else "standard"} strategy") + _uiState.value = _uiState.value.copy( isLoading = true, + isChunkedUpload = isChunked, + isPaused = false, + uploadStatusMessage = null, showUploadParamsDialog = false ) @@ -358,6 +408,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { currentUploadMaxDownloads = maxDownloads currentUploadExpiryHours = expiryHours + // Determine if this will be a chunked upload + val isChunked = UploadStrategySelector.shouldUseChunkedUpload(getApplication(), fileToUpload) + Log.d(TAG, "Upload will use ${if (isChunked) "chunked" else "standard"} strategy") + + // Update state with upload type + _uiState.value = _uiState.value.copy( + isChunkedUpload = isChunked, + isPaused = false, + uploadStatusMessage = null + ) + uploadController.startUploadService(fileToUpload, maxDownloads, expiryHours) } catch (e: Exception) { Log.e(TAG, "Error uploading dumpstate files", e) @@ -378,6 +439,39 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + /** + * Pause the current upload + */ + fun pauseUpload() { + Log.d(TAG, "Pause upload requested") + val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_PAUSE_UPLOAD + } + getApplication().startService(intent) + } + + /** + * Resume the paused upload + */ + fun resumeUpload() { + Log.d(TAG, "Resume upload requested") + val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_RESUME_UPLOAD + } + getApplication().startService(intent) + } + + /** + * Cancel the current upload + */ + fun cancelUpload() { + Log.d(TAG, "Cancel upload requested") + val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply { + action = AppConstants.ACTION_CANCEL_UPLOAD + } + getApplication().startService(intent) + } + override fun onCleared() { super.onCleared() // Unregister broadcast receiver