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
This commit is contained in:
2025-12-27 21:05:26 -05:00
parent e7335977c0
commit c80d523972
3 changed files with 155 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.app.Service.STOP_FOREGROUND_DETACH
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@@ -141,6 +142,9 @@ class FileUploadForegroundService : Service() {
Log.d(TAG, "Foreground service started successfully") Log.d(TAG, "Foreground service started successfully")
} }
// Mark upload as in progress
PreferencesUtil.setUploadInProgress(this, true, file.name)
// Prepare file for upload // Prepare file for upload
Log.d(TAG, "Starting file preparation...") Log.d(TAG, "Starting file preparation...")
FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback { FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback {
@@ -157,6 +161,7 @@ class FileUploadForegroundService : Service() {
override fun onError(e: Exception) { override fun onError(e: Exception) {
Log.e(TAG, "Error preparing file", e) Log.e(TAG, "Error preparing file", e)
PreferencesUtil.setUploadInProgress(this@FileUploadForegroundService, false, null)
sendErrorBroadcast("Error preparing file: ${e.message}") sendErrorBroadcast("Error preparing file: ${e.message}")
stopSelf() stopSelf()
} }
@@ -196,7 +201,7 @@ class FileUploadForegroundService : Service() {
uploadFuture.thenAccept { result -> uploadFuture.thenAccept { result ->
if (result.isSuccess) { if (result.isSuccess) {
Log.d(TAG, "Upload successful! Code: ${result.code}") 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) sendSuccessBroadcast(result.code)
} else { } else {
Log.e(TAG, "Upload failed: ${result.error}") Log.e(TAG, "Upload failed: ${result.error}")
@@ -204,19 +209,37 @@ class FileUploadForegroundService : Service() {
sendErrorBroadcast(result.error) 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({ 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() stopSelf()
}, 3000) }, 500)
}.exceptionally { ex -> }.exceptionally { ex ->
Log.e(TAG, "Exception during upload", ex) Log.e(TAG, "Exception during upload", ex)
updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true) updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true)
sendErrorBroadcast("Error during upload: ${ex.message}") 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({ Handler(Looper.getMainLooper()).postDelayed({
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH)
} else {
@Suppress("DEPRECATION")
stopForeground(false)
}
stopSelf() stopSelf()
}, 3000) }, 500)
null null
} }
} }
@@ -503,6 +526,27 @@ class FileUploadForegroundService : Service() {
notificationManager.notify(NOTIFICATION_ID, builder.build()) 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+) * Create the notification channel (required for Android 8.0+)
*/ */

View File

@@ -18,6 +18,8 @@ public class PreferencesUtil {
public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads"; 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_DEFAULT_EXPIRY_HOURS = "default_expiry_hours";
public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload"; 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 // Default values
private static final String DEFAULT_SERVER_ADDRESS = ""; private static final String DEFAULT_SERVER_ADDRESS = "";
@@ -255,4 +257,42 @@ public class PreferencesUtil {
return !address.isEmpty() && port > 0 && !clientKey.isEmpty(); 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);
}
} }

View File

@@ -14,12 +14,16 @@ import com.mattintech.simplelogupload.constant.AppConstants
import com.mattintech.simplelogupload.controller.FileController import com.mattintech.simplelogupload.controller.FileController
import com.mattintech.simplelogupload.controller.UploadController import com.mattintech.simplelogupload.controller.UploadController
import com.mattintech.simplelogupload.model.UploadResult 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.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/** /**
* UI state for the main screen * UI state for the main screen
@@ -56,10 +60,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private var currentUploadFileName: String? = null private var currentUploadFileName: String? = null
private var currentUploadMaxDownloads: Int = 0 private var currentUploadMaxDownloads: Int = 0
private var currentUploadExpiryHours: Int = 0 private var currentUploadExpiryHours: Int = 0
private var currentUploadTempZipFile: File? = null
init { init {
loadDumpstateFiles() loadDumpstateFiles()
registerUploadReceiver() 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") 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( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
uploadResult = result, uploadResult = result,
@@ -110,6 +133,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR) val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR)
Log.e(TAG, "Upload error: $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( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = error ?: "Unknown error", errorMessage = error ?: "Unknown error",
@@ -277,10 +309,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Upload selected dumpstate files * 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) { fun uploadSelectedDumpstates(maxDownloads: Int, expiryHours: Int) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
var zipFile: File? = null
try { try {
val files = _uiState.value.selectedDumpstateIndices val files = _uiState.value.selectedDumpstateIndices
.map { _uiState.value.dumpstateFiles[it] } .map { _uiState.value.dumpstateFiles[it] }
@@ -297,20 +330,46 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
showUploadParamsDialog = false 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 val fileToUpload: File
// TODO: Implement proper multi-file ZIP and upload
val firstFile = files.first() 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<Application>().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 // Track upload parameters for result construction
currentUploadFileName = firstFile.name currentUploadFileName = fileToUpload.name
currentUploadMaxDownloads = maxDownloads currentUploadMaxDownloads = maxDownloads
currentUploadExpiryHours = expiryHours currentUploadExpiryHours = expiryHours
uploadController.startUploadService(firstFile, maxDownloads, expiryHours) uploadController.startUploadService(fileToUpload, maxDownloads, expiryHours)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error uploading dumpstate files", e) 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( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
errorMessage = "Error uploading files: ${e.message}" errorMessage = "Error uploading files: ${e.message}"