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.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+)
*/

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_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);
}
}

View File

@@ -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<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
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}"