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:
@@ -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+)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user