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