From c80d5239727ea25ed1fb2d72daa7066041a36643 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 21:05:26 -0500 Subject: [PATCH] Add multi-file zip upload and persistent upload state tracking - Implement automatic zipping when multiple dumpstate files are selected - Single file uploads directly (preserves existing behavior) - Multiple files are zipped with timestamp naming (e.g., dumpstates_20251227_205530.zip) - Zip files stored in cache directory and cleaned up after upload completes or on error - Add upload progress persistence across app restarts - Track upload state in SharedPreferences to detect ongoing uploads - ViewModel checks for in-progress uploads on initialization and shows loading state - Fixes crash when reopening app during upload by properly maintaining state - Improve upload completion notification - Display upload code in notification text (e.g., "Upload complete! Code: ABC123") - Use stopForeground(STOP_FOREGROUND_DETACH) to keep notification visible after service stops - Notification persists until user dismisses it, ensuring code is always visible --- .../service/FileUploadForegroundService.kt | 54 ++++++++++++-- .../simplelogupload/util/PreferencesUtil.java | 40 ++++++++++ .../viewmodel/MainViewModel.kt | 73 +++++++++++++++++-- 3 files changed, 155 insertions(+), 12 deletions(-) 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 190923f..01215ad 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt @@ -6,6 +6,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.app.Service.STOP_FOREGROUND_DETACH import android.content.Context import android.content.Intent import android.os.Build @@ -141,6 +142,9 @@ class FileUploadForegroundService : Service() { Log.d(TAG, "Foreground service started successfully") } + // Mark upload as in progress + PreferencesUtil.setUploadInProgress(this, true, file.name) + // Prepare file for upload Log.d(TAG, "Starting file preparation...") FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback { @@ -157,6 +161,7 @@ class FileUploadForegroundService : Service() { override fun onError(e: Exception) { Log.e(TAG, "Error preparing file", e) + PreferencesUtil.setUploadInProgress(this@FileUploadForegroundService, false, null) sendErrorBroadcast("Error preparing file: ${e.message}") stopSelf() } @@ -196,7 +201,7 @@ class FileUploadForegroundService : Service() { uploadFuture.thenAccept { result -> if (result.isSuccess) { Log.d(TAG, "Upload successful! Code: ${result.code}") - updateNotification("Upload complete!", 100, 100, isComplete = true) + updateNotificationComplete("Upload complete! Code: ${result.code}", result.code) sendSuccessBroadcast(result.code) } else { Log.e(TAG, "Upload failed: ${result.error}") @@ -204,19 +209,37 @@ class FileUploadForegroundService : Service() { sendErrorBroadcast(result.error) } - // Give user a moment to see the final status, then stop service + // Clear upload in progress flag + PreferencesUtil.setUploadInProgress(this, false, null) + + // Detach notification from foreground service so it persists after service stops Handler(Looper.getMainLooper()).postDelayed({ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_DETACH) + } else { + @Suppress("DEPRECATION") + stopForeground(false) // Keep notification visible + } stopSelf() - }, 3000) + }, 500) }.exceptionally { ex -> Log.e(TAG, "Exception during upload", ex) updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true) sendErrorBroadcast("Error during upload: ${ex.message}") - // Give user a moment to see the error, then stop service + // Clear upload in progress flag + PreferencesUtil.setUploadInProgress(this, false, null) + + // 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() - }, 3000) + }, 500) null } } @@ -503,6 +526,27 @@ class FileUploadForegroundService : Service() { notificationManager.notify(NOTIFICATION_ID, builder.build()) } + /** + * Update notification with upload complete status and code + * + * @param status The status message + * @param uploadCode The upload code to display + */ + private fun updateNotificationComplete(status: String, uploadCode: String) { + val builder = notificationBuilder ?: return + + Log.d(TAG, "Updating notification with upload code: $uploadCode") + + builder.setContentTitle("Upload Successful!") + .setContentText(status) + .setStyle(NotificationCompat.BigTextStyle().bigText(status)) + .setProgress(0, 0, false) // Remove progress bar + .setOngoing(false) // Not ongoing, can be dismissed + .setAutoCancel(true) // Swipe to dismiss + + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + /** * Create the notification channel (required for Android 8.0+) */ diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/PreferencesUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/PreferencesUtil.java index 325cef9..a1cb3af 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/util/PreferencesUtil.java +++ b/app/src/main/java/com/mattintech/simplelogupload/util/PreferencesUtil.java @@ -18,6 +18,8 @@ public class PreferencesUtil { public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads"; public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours"; public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload"; + public static final String KEY_UPLOAD_IN_PROGRESS = "upload_in_progress"; + public static final String KEY_UPLOAD_FILE_NAME = "upload_file_name"; // Default values private static final String DEFAULT_SERVER_ADDRESS = ""; @@ -255,4 +257,42 @@ public class PreferencesUtil { return !address.isEmpty() && port > 0 && !clientKey.isEmpty(); } + + /** + * Check if an upload is currently in progress + * + * @param context The application context + * @return True if an upload is in progress, false otherwise + */ + public static boolean isUploadInProgress(Context context) { + return getPreferences(context).getBoolean(KEY_UPLOAD_IN_PROGRESS, false); + } + + /** + * Set the upload in progress status + * + * @param context The application context + * @param inProgress True if upload is in progress, false otherwise + * @param fileName The name of the file being uploaded (optional) + */ + public static void setUploadInProgress(Context context, boolean inProgress, String fileName) { + SharedPreferences.Editor editor = getPreferences(context).edit(); + editor.putBoolean(KEY_UPLOAD_IN_PROGRESS, inProgress); + if (inProgress && fileName != null) { + editor.putString(KEY_UPLOAD_FILE_NAME, fileName); + } else if (!inProgress) { + editor.remove(KEY_UPLOAD_FILE_NAME); + } + editor.apply(); + } + + /** + * Get the name of the file currently being uploaded + * + * @param context The application context + * @return The file name, or null if no upload is in progress + */ + public static String getUploadFileName(Context context) { + return getPreferences(context).getString(KEY_UPLOAD_FILE_NAME, null); + } } 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 73f8f40..03814fb 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt @@ -14,12 +14,16 @@ 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.util.PreferencesUtil +import com.mattintech.simplelogupload.util.ZipUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.io.File +import java.text.SimpleDateFormat +import java.util.* /** * UI state for the main screen @@ -56,10 +60,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private var currentUploadFileName: String? = null private var currentUploadMaxDownloads: Int = 0 private var currentUploadExpiryHours: Int = 0 + private var currentUploadTempZipFile: File? = null init { loadDumpstateFiles() registerUploadReceiver() + + // Check if there's an ongoing upload from a previous session + if (PreferencesUtil.isUploadInProgress(application)) { + val fileName = PreferencesUtil.getUploadFileName(application) + Log.d(TAG, "Detected ongoing upload: $fileName") + _uiState.value = _uiState.value.copy( + isLoading = true + ) + } } /** @@ -97,6 +111,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { UploadResult("Upload completed but code is missing") } + // 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 upload: ${tempFile.name}, deleted: $deleted") + } + currentUploadTempZipFile = null + } + _uiState.value = _uiState.value.copy( isLoading = false, uploadResult = result, @@ -110,6 +133,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR) Log.e(TAG, "Upload error: $error") + // 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 error: ${tempFile.name}, deleted: $deleted") + } + currentUploadTempZipFile = null + } + _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = error ?: "Unknown error", @@ -277,10 +309,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** * Upload selected dumpstate files - * Uploads multiple files by combining them into a ZIP first + * If multiple files are selected, combines them into a ZIP first */ fun uploadSelectedDumpstates(maxDownloads: Int, expiryHours: Int) { viewModelScope.launch(Dispatchers.IO) { + var zipFile: File? = null try { val files = _uiState.value.selectedDumpstateIndices .map { _uiState.value.dumpstateFiles[it] } @@ -297,20 +330,46 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { showUploadParamsDialog = false ) - Log.d(TAG, "Starting upload for ${files.size} dumpstate files") + Log.d(TAG, "Starting upload for ${files.size} dumpstate file(s)") - // For now, upload the first file using foreground service - // TODO: Implement proper multi-file ZIP and upload - val firstFile = files.first() + val fileToUpload: File + + if (files.size == 1) { + // Single file: upload directly + fileToUpload = files.first() + currentUploadTempZipFile = null + Log.d(TAG, "Uploading single dumpstate file: ${fileToUpload.name}") + } else { + // Multiple files: zip them first + Log.d(TAG, "Zipping ${files.size} dumpstate files") + + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val zipFileName = "dumpstates_$timestamp" + val cacheDir = getApplication().cacheDir + + zipFile = ZipUtil.zipFiles(files, cacheDir, zipFileName, null) + fileToUpload = zipFile + currentUploadTempZipFile = zipFile + Log.d(TAG, "Created zip file: ${fileToUpload.name} (${fileToUpload.length()} bytes)") + } // Track upload parameters for result construction - currentUploadFileName = firstFile.name + currentUploadFileName = fileToUpload.name currentUploadMaxDownloads = maxDownloads currentUploadExpiryHours = expiryHours - uploadController.startUploadService(firstFile, maxDownloads, expiryHours) + uploadController.startUploadService(fileToUpload, maxDownloads, expiryHours) } catch (e: Exception) { Log.e(TAG, "Error uploading dumpstate files", e) + + // Clean up zip file if it was created + zipFile?.let { + if (it.exists()) { + val deleted = it.delete() + Log.d(TAG, "Cleaned up zip file: ${it.name}, deleted: $deleted") + } + } + _uiState.value = _uiState.value.copy( isLoading = false, errorMessage = "Error uploading files: ${e.message}"