From eff3478a97448f0cd84f0fcee8ed8f864ce1859d Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 22:56:07 -0500 Subject: [PATCH] Fix ANR during chunked uploads and file compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Changes: - Convert ChunkedUploadManager.java to Kotlin with coroutines - Convert ZipUtil.java to Kotlin with Dispatchers.IO - Convert FileController.java to Kotlin ANR Fixes: - All file I/O now runs on Dispatchers.IO (prevents main thread blocking) - Chunk reading (4-10MB) moved to background thread - File compression (zipping) moved to background thread - Instant cancellation with proper coroutine handling Chunking Improvements: - Update thresholds to match chunk sizes (4MB mobile, 10MB WiFi) - Files smaller than chunk size use standard upload - Proper zipping logic: skip single .zip files, zip everything else Progress & UX: - Add PREPARING state for file compression phase - Show 'Compressing file: X%' in notification during zip - Throttle updates to 500ms for smooth notification display - Broadcast ACTION_ZIP_PROGRESS for in-app UI - ViewModel listens and displays zip progress - Cancel button visible and functional during all phases Cancellation: - Instant cancel during zipping (checks every 8KB) - Clean cancellation using CancellationException - Automatic cleanup of partial zip files - No error logs for user-initiated cancellation Files Changed: - ChunkedUploadManager: Java → Kotlin, uses coroutines + Dispatchers.IO - ZipUtil: Java → Kotlin, cancellable with progress throttling - FileController: Java → Kotlin, proper async job management - UploadStrategySelector: Updated chunk size thresholds - FileUploadForegroundService: Added PREPARING state, zip progress handling - MainViewModel: Listen for ACTION_ZIP_PROGRESS, display status - AppConstants: Added ACTION_ZIP_PROGRESS constant --- .../constant/AppConstants.java | 1 + .../controller/FileController.java | 266 ----- .../controller/FileController.kt | 381 ++++++ .../service/FileUploadForegroundService.kt | 61 +- .../upload/ChunkedUploadManager.java | 1046 ----------------- .../upload/ChunkedUploadManager.kt | 811 +++++++++++++ .../upload/UploadStrategySelector.java | 7 +- .../simplelogupload/util/ZipUtil.java | 228 ---- .../simplelogupload/util/ZipUtil.kt | 239 ++++ .../viewmodel/MainViewModel.kt | 9 + 10 files changed, 1502 insertions(+), 1547 deletions(-) delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/controller/FileController.kt delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.kt delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt diff --git a/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java index 4853f37..eb287fe 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java +++ b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java @@ -10,6 +10,7 @@ public class AppConstants { public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE"; public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; public static final String ACTION_UPLOAD_PROGRESS = "com.mattintech.simplelogupload.UPLOAD_PROGRESS"; + public static final String ACTION_ZIP_PROGRESS = "com.mattintech.simplelogupload.ZIP_PROGRESS"; public static final String ACTION_UPLOAD_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED"; public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED"; public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED"; diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java b/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java deleted file mode 100644 index 69a8ec5..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java +++ /dev/null @@ -1,266 +0,0 @@ -package com.mattintech.simplelogupload.controller; - -import android.content.Context; -import android.net.Uri; -import android.util.Log; - -import com.mattintech.simplelogupload.constant.AppConstants; -import com.mattintech.simplelogupload.constant.FileConstants; -import com.mattintech.simplelogupload.util.FileUtil; -import com.mattintech.simplelogupload.util.ZipUtil; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; - -/** - * Controller for file operations - */ -public class FileController { - private static final String TAG = AppConstants.APP_TAG + "FileController"; - - /** - * Interface for file preparation callbacks - */ - public interface FilePrepareCallback { - void onSuccess(File preparedFile); - void onProgress(int progress); - void onError(Exception e); - } - - /** - * Find the most recent dumpstate file - * - * @return The most recent dumpstate file or null if none found - */ - public static File getMostRecentDumpStateFile() { - return FileUtil.findMostRecentDumpStateFile(); - } - - /** - * Find all dumpstate files - * - * @return Array of dumpstate files - */ - public static File[] getAllDumpStateFiles() { - return FileUtil.findAllDumpStateFiles(); - } - - /** - * Prepare a file for upload - ensures the file is zipped - * - * @param file The file to prepare - * @param callback Callback for progress updates and completion - */ - public static void prepareFileForUpload(File file, FilePrepareCallback callback) { - if (file == null || !file.exists()) { - callback.onError(new IOException("File does not exist")); - return; - } - - // Check file size - if (!FileUtil.isValidFileSize(file)) { - callback.onError(new IOException("File size exceeds limit of " + - FileConstants.MAX_FILE_SIZE + " bytes")); - return; - } - - // If already a zip file, just return it - if (FileUtil.isZipFile(file)) { - callback.onSuccess(file); - return; - } - - // Otherwise, compress it - try { - ZipUtil.zipFile(file, new ZipUtil.ZipProgressListener() { - @Override - public void onProgress(long bytesProcessed, long totalBytes) { - int progress = (int) ((bytesProcessed * 100) / totalBytes); - callback.onProgress(progress); - } - - @Override - public void onComplete(File zipFile) { - callback.onSuccess(zipFile); - } - - @Override - public void onError(Exception e) { - callback.onError(e); - } - }); - } catch (IOException e) { - Log.e(TAG, "Error preparing file for upload", e); - callback.onError(e); - } - } - - /** - * Prepare multiple files for upload by zipping them into a single file - * - * @param files The list of files to prepare - * @param context The context - * @param callback Callback for progress updates and completion - */ - public static void prepareMultipleFilesForUpload(List files, Context context, FilePrepareCallback callback) { - if (files == null || files.isEmpty()) { - callback.onError(new IOException("No files provided")); - return; - } - - // Create a unique filename for the zip - String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION; - - try { - // Use cache directory for temporary zip storage - ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, new ZipUtil.ZipProgressListener() { - @Override - public void onProgress(long bytesProcessed, long totalBytes) { - int progress = (int) ((bytesProcessed * 100) / Math.max(totalBytes, 1)); // Avoid division by zero - callback.onProgress(progress); - } - - @Override - public void onComplete(File zipFile) { - callback.onSuccess(zipFile); - } - - @Override - public void onError(Exception e) { - callback.onError(e); - } - }); - } catch (IOException e) { - Log.e(TAG, "Error preparing multiple files for upload", e); - callback.onError(e); - } - } - - /** - * Prepare a file for upload asynchronously - * - * @param file The file to prepare - * @return CompletableFuture containing the prepared file - */ - public static CompletableFuture prepareFileForUploadAsync(File file) { - return CompletableFuture.supplyAsync(() -> { - try { - if (file == null || !file.exists()) { - throw new IOException("File does not exist"); - } - - // Check file size - if (!FileUtil.isValidFileSize(file)) { - throw new IOException("File size exceeds limit of " + - FileConstants.MAX_FILE_SIZE + " bytes"); - } - - // If already a zip file, just return it - if (FileUtil.isZipFile(file)) { - return file; - } - - // Otherwise, compress it - return ZipUtil.zipFile(file); - } catch (IOException e) { - Log.e(TAG, "Error preparing file for upload", e); - throw new RuntimeException(e); - } - }, Executors.newSingleThreadExecutor()); - } - - /** - * Prepare multiple files for upload asynchronously - * - * @param files The list of files to prepare - * @param context The context - * @return CompletableFuture containing the prepared file - */ - public static CompletableFuture prepareMultipleFilesForUploadAsync(List files, Context context) { - return CompletableFuture.supplyAsync(() -> { - try { - if (files == null || files.isEmpty()) { - throw new IOException("No files provided"); - } - - // Create a unique filename for the zip - String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION; - - // Use cache directory for temporary zip storage - return ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, null); - } catch (IOException e) { - Log.e(TAG, "Error preparing multiple files for upload", e); - throw new RuntimeException(e); - } - }, Executors.newSingleThreadExecutor()); - } - - /** - * Save a content URI to a temporary file - * - * @param context The context - * @param uri The content URI - * @param filename The desired filename - * @return The saved file - * @throws IOException If saving fails - */ - public static File saveContentUriToFile(Context context, Uri uri, String filename) throws IOException { - if (context == null || uri == null) { - throw new IOException("Invalid context or URI"); - } - - // Create a temporary file - File outputFile = new File(context.getCacheDir(), filename); - - try (InputStream inputStream = context.getContentResolver().openInputStream(uri); - FileOutputStream outputStream = new FileOutputStream(outputFile)) { - - if (inputStream == null) { - throw new IOException("Could not open input stream for URI: " + uri); - } - - byte[] buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = inputStream.read(buffer)) != -1) { - outputStream.write(buffer, 0, bytesRead); - } - } - - return outputFile; - } - - /** - * Validates if the file size is within the allowable limits - * - * @param file The file to validate - * @return true if the file size is valid, false otherwise - */ - public static boolean isValidFileSize(File file) { - return FileUtil.isValidFileSize(file); - } - - /** - * Save a content URI to a temporary file asynchronously - * - * @param context The context - * @param uri The content URI - * @param filename The desired filename - * @return CompletableFuture containing the saved file - */ - public static CompletableFuture saveContentUriToFileAsync( - Context context, Uri uri, String filename) { - return CompletableFuture.supplyAsync(() -> { - try { - return saveContentUriToFile(context, uri, filename); - } catch (IOException e) { - Log.e(TAG, "Error saving content URI to file", e); - throw new RuntimeException(e); - } - }, Executors.newSingleThreadExecutor()); - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.kt b/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.kt new file mode 100644 index 0000000..e0a6449 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.kt @@ -0,0 +1,381 @@ +package com.mattintech.simplelogupload.controller + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.mattintech.simplelogupload.constant.AppConstants +import com.mattintech.simplelogupload.constant.FileConstants +import com.mattintech.simplelogupload.util.FileUtil +import com.mattintech.simplelogupload.util.ZipUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream + +/** + * Controller for file operations using Kotlin coroutines + */ +object FileController { + private const val TAG = AppConstants.APP_TAG + "FileController" + + // Track current zip job for cancellation + private var currentZipJob: kotlinx.coroutines.Job? = null + + /** + * Interface for file preparation callbacks + */ + interface FilePrepareCallback { + fun onSuccess(preparedFile: File) + fun onProgress(progress: Int) + fun onError(e: Exception) + } + + /** + * Find the most recent dumpstate file + */ + @JvmStatic + fun getMostRecentDumpStateFile(): File? { + return FileUtil.findMostRecentDumpStateFile() + } + + /** + * Find all dumpstate files + */ + @JvmStatic + fun getAllDumpStateFiles(): Array { + return FileUtil.findAllDumpStateFiles() + } + + /** + * Prepare a file for upload - ensures the file is zipped if needed + * Runs zipping on Dispatchers.IO to prevent ANR + * + * @param file The file to prepare + * @param callback Callback for progress updates and completion + */ + @JvmStatic + fun prepareFileForUpload(file: File, callback: FilePrepareCallback) { + if (!file.exists()) { + callback.onError(IOException("File does not exist")) + return + } + + // Check file size + if (!FileUtil.isValidFileSize(file)) { + callback.onError( + IOException("File size exceeds limit of ${FileConstants.MAX_FILE_SIZE} bytes") + ) + return + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(file)) { + callback.onSuccess(file) + return + } + + // Otherwise, compress it on a background thread + currentZipJob = CoroutineScope(Dispatchers.IO).launch { + try { + ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener { + override fun onProgress(bytesProcessed: Long, totalBytes: Long) { + val progress = ((bytesProcessed * 100) / totalBytes).toInt() + Log.d(TAG, "Zip progress callback: $progress%") + // Post callback to main thread for UI updates + CoroutineScope(Dispatchers.Main).launch { + callback.onProgress(progress) + } + } + + override fun onComplete(zipFile: File) { + Log.d(TAG, "Zip complete callback") + // Post callback to main thread + CoroutineScope(Dispatchers.Main).launch { + callback.onSuccess(zipFile) + currentZipJob = null + } + } + + override fun onError(e: Exception) { + Log.e(TAG, "Zip error callback", e) + // Post callback to main thread + CoroutineScope(Dispatchers.Main).launch { + callback.onError(e) + currentZipJob = null + } + } + }) + } catch (e: CancellationException) { + // Cancellation is expected, just clean up + Log.d(TAG, "File preparation cancelled") + currentZipJob = null + // Don't call error callback for cancellation + } catch (e: Exception) { + Log.e(TAG, "Error preparing file for upload", e) + CoroutineScope(Dispatchers.Main).launch { + callback.onError(e) + currentZipJob = null + } + } + } + } + + /** + * Prepare multiple files for upload by zipping them into a single file + * Runs zipping on Dispatchers.IO to prevent ANR + * + * @param files The list of files to prepare + * @param context The context + * @param callback Callback for progress updates and completion + */ + @JvmStatic + fun prepareMultipleFilesForUpload( + files: List, + context: Context, + callback: FilePrepareCallback + ) { + if (files.isEmpty()) { + callback.onError(IOException("No files provided")) + return + } + + // Create a unique filename for the zip + val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}" + + // Zip files on background thread + CoroutineScope(Dispatchers.IO).launch { + try { + ZipUtil.zipFiles( + files, + context.cacheDir, + zipFileName, + object : ZipUtil.ZipProgressListener { + override fun onProgress(bytesProcessed: Long, totalBytes: Long) { + val progress = if (totalBytes > 0) { + ((bytesProcessed * 100) / totalBytes).toInt() + } else { + 0 + } + // Post callback to main thread for UI updates + CoroutineScope(Dispatchers.Main).launch { + callback.onProgress(progress) + } + } + + override fun onComplete(zipFile: File) { + // Post callback to main thread + CoroutineScope(Dispatchers.Main).launch { + callback.onSuccess(zipFile) + } + } + + override fun onError(e: Exception) { + // Post callback to main thread + CoroutineScope(Dispatchers.Main).launch { + callback.onError(e) + } + } + } + ) + } catch (e: Exception) { + Log.e(TAG, "Error preparing multiple files for upload", e) + CoroutineScope(Dispatchers.Main).launch { + callback.onError(e) + } + } + } + } + + /** + * Save a content URI to a temporary file + * + * @param context The context + * @param uri The content URI + * @param filename The desired filename + * @return The saved file + * @throws IOException If saving fails + */ + @JvmStatic + @Throws(IOException::class) + fun saveContentUriToFile(context: Context, uri: Uri, filename: String): File { + // Create a temporary file + val outputFile = File(context.cacheDir, filename) + + context.contentResolver.openInputStream(uri)?.use { inputStream -> + FileOutputStream(outputFile).use { outputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + } ?: throw IOException("Could not open input stream for URI: $uri") + + return outputFile + } + + /** + * Validates if the file size is within the allowable limits + * + * @param file The file to validate + * @return true if the file size is valid, false otherwise + */ + @JvmStatic + fun isValidFileSize(file: File): Boolean { + return FileUtil.isValidFileSize(file) + } + + /** + * Cancel any ongoing file preparation (zipping) operation + */ + @JvmStatic + fun cancelPreparation() { + currentZipJob?.cancel() + currentZipJob = null + Log.d(TAG, "Cancelled file preparation") + } + + /** + * Prepare a file for upload asynchronously + * Returns a CompletableFuture for Java compatibility + * + * @param file The file to prepare + * @return CompletableFuture containing the prepared file + */ + @JvmStatic + fun prepareFileForUploadAsync(file: File): java.util.concurrent.CompletableFuture { + val future = java.util.concurrent.CompletableFuture() + + if (!file.exists()) { + future.completeExceptionally(IOException("File does not exist")) + return future + } + + // Check file size + if (!FileUtil.isValidFileSize(file)) { + future.completeExceptionally( + IOException("File size exceeds limit of ${FileConstants.MAX_FILE_SIZE} bytes") + ) + return future + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(file)) { + future.complete(file) + return future + } + + // Otherwise, compress it on background thread + CoroutineScope(Dispatchers.IO).launch { + try { + var resultFile: File? = null + ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener { + override fun onProgress(bytesProcessed: Long, totalBytes: Long) { + // Progress not needed for async API + } + + override fun onComplete(zipFile: File) { + resultFile = zipFile + future.complete(zipFile) + } + + override fun onError(e: Exception) { + future.completeExceptionally(e) + } + }) + } catch (e: Exception) { + Log.e(TAG, "Error preparing file for upload", e) + future.completeExceptionally(e) + } + } + + return future + } + + /** + * Prepare multiple files for upload asynchronously + * Returns a CompletableFuture for Java compatibility + * + * @param files The list of files to prepare + * @param context The context + * @return CompletableFuture containing the prepared file + */ + @JvmStatic + fun prepareMultipleFilesForUploadAsync( + files: List, + context: Context + ): java.util.concurrent.CompletableFuture { + val future = java.util.concurrent.CompletableFuture() + + if (files.isEmpty()) { + future.completeExceptionally(IOException("No files provided")) + return future + } + + // Create a unique filename for the zip + val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}" + + // Zip files on background thread + CoroutineScope(Dispatchers.IO).launch { + try { + ZipUtil.zipFiles( + files, + context.cacheDir, + zipFileName, + object : ZipUtil.ZipProgressListener { + override fun onProgress(bytesProcessed: Long, totalBytes: Long) { + // Progress not needed for async API + } + + override fun onComplete(zipFile: File) { + future.complete(zipFile) + } + + override fun onError(e: Exception) { + future.completeExceptionally(e) + } + } + ) + } catch (e: Exception) { + Log.e(TAG, "Error preparing multiple files for upload", e) + future.completeExceptionally(e) + } + } + + return future + } + + /** + * Save a content URI to a temporary file asynchronously + * Returns a CompletableFuture for Java compatibility + * + * @param context The context + * @param uri The content URI + * @param filename The desired filename + * @return CompletableFuture containing the saved file + */ + @JvmStatic + fun saveContentUriToFileAsync( + context: Context, + uri: Uri, + filename: String + ): java.util.concurrent.CompletableFuture { + val future = java.util.concurrent.CompletableFuture() + + CoroutineScope(Dispatchers.IO).launch { + try { + val file = saveContentUriToFile(context, uri, filename) + future.complete(file) + } catch (e: Exception) { + Log.e(TAG, "Error saving content URI to file", e) + future.completeExceptionally(e) + } + } + + return future + } +} 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 3c3821b..8751d2b 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt @@ -69,10 +69,11 @@ class FileUploadForegroundService : Service() { // Upload state tracking private var currentUploadManager: ChunkedUploadManager? = null private var currentStandardUploadCall: Call? = null + private var currentZipJob: kotlinx.coroutines.Job? = null private var uploadState: UploadState = UploadState.IDLE enum class UploadState { - IDLE, UPLOADING, PAUSED, CANCELLED + IDLE, PREPARING, UPLOADING, PAUSED, CANCELLED } override fun onCreate() { @@ -170,18 +171,51 @@ class FileUploadForegroundService : Service() { // Mark upload as in progress PreferencesUtil.setUploadInProgress(this, true, file.name) - // Prepare file for upload + // Prepare file for upload (zip if needed based on extension and file type) Log.d(TAG, "Starting file preparation...") + + // Check if file needs zipping to show appropriate message + val needsZipping = !com.mattintech.simplelogupload.util.FileUtil.isZipFile(file) + + // Set state to PREPARING + uploadState = UploadState.PREPARING + FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback { override fun onSuccess(preparedFile: File) { + if (uploadState == UploadState.CANCELLED) { + Log.d(TAG, "Upload cancelled during preparation") + return + } + Log.d(TAG, "File prepared for upload: ${preparedFile.name}") Log.d(TAG, "Starting upload process...") + uploadState = UploadState.IDLE uploadFile(preparedFile, maxDownloads, expiryHours) } override fun onProgress(progress: Int) { - Log.d(TAG, "File preparation progress: $progress%") - updateNotification("Preparing file: $progress%", progress, 100) + if (uploadState == UploadState.CANCELLED) { + return + } + + // Throttle notification updates to every 500ms to ensure visibility + val currentTime = System.currentTimeMillis() + val shouldUpdate = currentTime - lastProgressUpdate >= 500 || progress == 100 + + Log.d(TAG, "File preparation progress: $progress% (shouldUpdate: $shouldUpdate)") + + if (shouldUpdate) { + lastProgressUpdate = currentTime + val message = if (needsZipping) { + "Compressing file: $progress%" + } else { + "Preparing file: $progress%" + } + updateNotification(message, progress, 100) + } + + // Always send broadcast for in-app UI (not throttled) + sendZipProgressBroadcast(progress) } override fun onError(e: Exception) { @@ -580,6 +614,10 @@ class FileUploadForegroundService : Service() { builder.clearActions() // Remove old actions when (uploadState) { + UploadState.PREPARING -> { + // Show Cancel button during file preparation/zipping + builder.addAction(createCancelAction()) + } UploadState.PAUSED -> { // Show Resume and Cancel buttons builder.addAction(createResumeAction()) @@ -696,6 +734,18 @@ class FileUploadForegroundService : Service() { LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } + /** + * Send a broadcast with zip/compression progress update + */ + private fun sendZipProgressBroadcast(percentComplete: Int) { + val intent = Intent(AppConstants.ACTION_ZIP_PROGRESS).apply { + putExtra(AppConstants.EXTRA_PERCENT_COMPLETE, percentComplete) + } + + // Send local broadcast to the ViewModel + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + /** * Send a broadcast when upload is paused */ @@ -839,6 +889,9 @@ class FileUploadForegroundService : Service() { uploadState = UploadState.CANCELLED + // Cancel file preparation (zipping) if in progress + FileController.cancelPreparation() + // Cancel chunked upload currentUploadManager?.let { manager -> manager.cancelUpload() diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java b/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java deleted file mode 100644 index 6be9fd9..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java +++ /dev/null @@ -1,1046 +0,0 @@ -package com.mattintech.simplelogupload.upload; - -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.net.NetworkRequest; -import android.util.Log; - -import com.mattintech.simplelogupload.constant.AppConstants; -import com.mattintech.simplelogupload.constant.NetworkConstants; -import com.mattintech.simplelogupload.model.UploadResult; -import com.mattintech.simplelogupload.util.PreferencesUtil; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -/** - * Manages chunked uploads for large files. - * This class handles splitting a large file into chunks, uploading them, - * tracking progress, and reassembling on the server. - */ -public class ChunkedUploadManager { - private static final String TAG = AppConstants.APP_TAG + "ChunkedUploadManager"; - - // Chunk size configuration - private static final int WIFI_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB for WiFi - private static final int MOBILE_CHUNK_SIZE = 4 * 1024 * 1024; // 4MB for mobile networks (increased from 2MB) - private static final int DEFAULT_CHUNK_SIZE = WIFI_CHUNK_SIZE; - - // Max parallel uploads - private static final int WIFI_MAX_PARALLEL = 3; - private static final int MOBILE_MAX_PARALLEL = 2; // 2 parallel chunks on mobile (testing with nginx config updates) - - // Retry configuration - private static final int MAX_RETRY_ATTEMPTS = 3; - private static final int BASE_RETRY_DELAY_MS = 1000; - - // Context and network - private final Context context; - private boolean isWifiConnection; - private NetworkCallback networkCallback; - private final ConnectivityManager connectivityManager; - - // Upload parameters - private final File file; - private final int maxDownloads; - private final int expiryHours; - private final int chunkSize; - private final int totalChunks; - private final int maxParallelUploads; - - // Upload state - private String uploadId; - private final Set completedChunks = new HashSet<>(); - private final Set inProgressChunks = new HashSet<>(); - private final AtomicBoolean isPaused = new AtomicBoolean(false); - private final AtomicBoolean isCancelled = new AtomicBoolean(false); - private final AtomicInteger activeUploads = new AtomicInteger(0); - private final AtomicLong uploadedBytes = new AtomicLong(0); - - // Progress tracking - private UploadProgressListener progressListener; - private final long totalBytes; - - // OkHttpClient for uploads - private OkHttpClient client; - - /** - * Interface for tracking upload progress - */ - public interface UploadProgressListener { - void onProgress(long bytesUploaded, long totalBytes, int percentComplete); - void onChunkComplete(int chunkIndex, int totalChunks); - void onUploadComplete(UploadResult result); - void onError(String error); - void onPaused(); - void onResumed(); - } - - /** - * Create a new chunked upload manager - * - * @param context The application context - * @param file The file to upload - * @param maxDownloads Maximum number of downloads - * @param expiryHours Expiry time in hours - */ - public ChunkedUploadManager(Context context, File file, int maxDownloads, int expiryHours) { - this.context = context.getApplicationContext(); - this.file = file; - this.maxDownloads = maxDownloads; - this.expiryHours = expiryHours; - this.totalBytes = file.length(); - - // Set up connectivity manager for network monitoring - this.connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - - // Determine initial connection type - detectConnectionType(); - - // Configure chunk size based on connection type - this.chunkSize = isWifiConnection ? WIFI_CHUNK_SIZE : MOBILE_CHUNK_SIZE; - - // Configure max parallel uploads based on connection type and user preference - boolean parallelEnabled = PreferencesUtil.isParallelUploadsEnabled(context); - if (isWifiConnection) { - this.maxParallelUploads = WIFI_MAX_PARALLEL; - } else { - // On mobile, use parallel uploads only if enabled in settings - this.maxParallelUploads = parallelEnabled ? MOBILE_MAX_PARALLEL : 1; - } - - // Calculate total chunks - this.totalChunks = (int) Math.ceil((double) file.length() / chunkSize); - - // Setup HTTP client with appropriate timeouts based on connection type - setupHttpClient(); - - // Generate upload ID - this.uploadId = UUID.randomUUID().toString(); - - Log.d(TAG, "Created chunked upload manager for file: " + file.getName()); - Log.d(TAG, "Total size: " + totalBytes + " bytes, Chunks: " + totalChunks); - Log.d(TAG, "Connection type: " + (isWifiConnection ? "WiFi" : "Mobile") + - ", Chunk size: " + chunkSize + " bytes" + - ", Parallel uploads: " + maxParallelUploads + (parallelEnabled ? " (enabled)" : " (disabled)")); - } - - /** - * Set a listener for upload progress events - * - * @param listener The progress listener - */ - public void setProgressListener(UploadProgressListener listener) { - this.progressListener = listener; - } - - /** - * Start the upload process - * - * @return A CompletableFuture that completes when the upload is done - */ - public CompletableFuture startUpload() { - CompletableFuture resultFuture = new CompletableFuture<>(); - - // Reset state - completedChunks.clear(); - inProgressChunks.clear(); - uploadedBytes.set(0); - isPaused.set(false); - isCancelled.set(false); - - // Start network monitoring - registerNetworkCallback(); - - // Begin upload process by initializing a session with the server - initializeUploadSession() - .thenAccept(sessionId -> { - if (sessionId != null && !sessionId.isEmpty()) { - this.uploadId = sessionId; - Log.d(TAG, "Upload session initialized with ID: " + uploadId); - - // Start uploading chunks - uploadNextChunks() - .thenAccept(isComplete -> { - if (isComplete) { - // All chunks uploaded, finalize the upload - finalizeUpload() - .thenAccept(resultFuture::complete) - .exceptionally(ex -> { - Log.e(TAG, "Error finalizing upload", ex); - resultFuture.complete(new UploadResult("Failed to finalize upload: " + ex.getMessage())); - return null; - }); - } else { - resultFuture.complete(new UploadResult("Upload incomplete or cancelled")); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error during chunk uploading", ex); - resultFuture.complete(new UploadResult("Chunk upload error: " + ex.getMessage())); - return null; - }); - } else { - Log.e(TAG, "Failed to initialize upload session"); - resultFuture.complete(new UploadResult("Failed to initialize upload session")); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error initializing upload session", ex); - resultFuture.complete(new UploadResult("Session initialization error: " + ex.getMessage())); - return null; - }); - - return resultFuture; - } - - /** - * Initialize an upload session with the server - * - * @return A future that completes with the session ID - */ - private CompletableFuture initializeUploadSession() { - CompletableFuture future = new CompletableFuture<>(); - - // Get server URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Create the session initialization URL - String initUrl = serverBaseUrl + "/upload/start"; - - // Build the request body with file metadata - RequestBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("filename", file.getName()) - .addFormDataPart("filesize", String.valueOf(totalBytes)) - .addFormDataPart("chunk_size", String.valueOf(chunkSize)) - .addFormDataPart("total_chunks", String.valueOf(totalChunks)) - .addFormDataPart("max_downloads", String.valueOf(maxDownloads)) - .addFormDataPart("expiry_hours", String.valueOf(expiryHours)) - .build(); - - // Build the request - Request request = new Request.Builder() - .url(initUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .post(requestBody) - .build(); - - Log.d(TAG, "Initializing upload session..."); - - // Execute the request - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Failed to initialize upload session", e); - future.complete(null); // Complete with null to indicate failure - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - try { - String responseBody = response.body().string(); - JSONObject jsonResponse = new JSONObject(responseBody); - String sessionId = jsonResponse.getString("upload_id"); - future.complete(sessionId); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse session initialization response", e); - future.complete(null); - } - } else { - Log.e(TAG, "Session initialization failed with code: " + response.code()); - future.complete(null); - } - } - }); - - return future; - } - - /** - * Upload chunks to the server - * - * @return A future that completes when all chunks are uploaded - */ - // This runnable is defined as a class member to avoid initialization issues - private Runnable uploadProgressChecker; - private CompletableFuture uploadProgressFuture; - - /** - * Upload chunks to the server - * - * @return A future that completes when all chunks are uploaded - */ - private CompletableFuture uploadNextChunks() { - uploadProgressFuture = new CompletableFuture<>(); - - // Define the progress checker runnable - uploadProgressChecker = new Runnable() { - @Override - public void run() { - if (isCancelled.get()) { - uploadProgressFuture.complete(false); - return; - } - - if (isPaused.get()) { - // Upload is paused, do nothing - return; - } - - // If all chunks are complete, verify each index was uploaded - if (completedChunks.size() == totalChunks) { - // Additional verification: check if all required chunk indexes exist - boolean allChunksUploaded = true; - Set missingChunks = new HashSet<>(); - - for (int i = 0; i < totalChunks; i++) { - if (!completedChunks.contains(i)) { - missingChunks.add(i); - allChunksUploaded = false; - } - } - - if (allChunksUploaded) { - Log.d(TAG, "All chunks uploaded successfully"); - uploadProgressFuture.complete(true); - return; - } else { - Log.e(TAG, "Missing chunks despite having " + completedChunks.size() + - " chunks completed. Missing indexes: " + missingChunks); - // Continue with upload process to retry missing chunks - } - } - - // Find next chunks to upload - for (int i = 0; i < totalChunks && activeUploads.get() < maxParallelUploads; i++) { - final int chunkIndex = i; // Create final variable for use in lambda - if (!completedChunks.contains(chunkIndex) && !inProgressChunks.contains(chunkIndex)) { - // Start upload for this chunk - inProgressChunks.add(chunkIndex); - activeUploads.incrementAndGet(); - - uploadChunk(chunkIndex) - .thenAccept(success -> { - // Remove from in-progress - inProgressChunks.remove(chunkIndex); - activeUploads.decrementAndGet(); - - if (success) { - // Add to completed - completedChunks.add(chunkIndex); - - // Notify listener - if (progressListener != null) { - progressListener.onChunkComplete(chunkIndex, totalChunks); - } - - // Check progress again - uploadProgressChecker.run(); - } else if (!isCancelled.get()) { - // Retry failed chunks if not cancelled - if (!isPaused.get()) { - Log.d(TAG, "Scheduling retry for chunk " + chunkIndex); - // Reschedule after a delay - // Using a simple retry here, but could implement exponential backoff - java.util.concurrent.ScheduledExecutorService scheduler = - java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); - scheduler.schedule(uploadProgressChecker, 1000, TimeUnit.MILLISECONDS); - scheduler.shutdown(); - } - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error uploading chunk " + chunkIndex, ex); - inProgressChunks.remove(chunkIndex); - activeUploads.decrementAndGet(); - return null; - }); - } - } - - // If no active uploads and no more chunks to upload, but not all chunks are complete, - // something went wrong - if (activeUploads.get() == 0 && inProgressChunks.isEmpty() && - completedChunks.size() < totalChunks && !isPaused.get()) { - Log.e(TAG, "Upload stalled with " + completedChunks.size() + "/" + totalChunks + " chunks completed"); - uploadProgressFuture.complete(false); - } - } - }; - - // Start uploading chunks - uploadProgressChecker.run(); - - return uploadProgressFuture; - } - - /** - * Upload a single chunk to the server - * - * @param chunkIndex The index of the chunk to upload - * @return A future that completes when the chunk is uploaded - */ - private CompletableFuture uploadChunk(int chunkIndex) { - CompletableFuture future = new CompletableFuture<>(); - - // Get server URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Create the chunk upload URL - String chunkUrl = serverBaseUrl + "/upload/chunk"; - - try { - // Calculate chunk start and end positions - long start = (long) chunkIndex * chunkSize; - long end = Math.min(start + chunkSize, file.length()); - int currentChunkSize = (int) (end - start); - - Log.d(TAG, "Uploading chunk " + chunkIndex + " (" + currentChunkSize + " bytes)"); - - // Read the chunk data - byte[] buffer = new byte[currentChunkSize]; - try (FileInputStream inputStream = new FileInputStream(file)) { - inputStream.skip(start); - int bytesRead = inputStream.read(buffer); - if (bytesRead != currentChunkSize) { - Log.e(TAG, "Failed to read chunk data: expected " + currentChunkSize + - " bytes, got " + bytesRead); - future.complete(false); - return future; - } - } - - // Create the request body - RequestBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("upload_id", uploadId) - .addFormDataPart("chunk_index", String.valueOf(chunkIndex)) - .addFormDataPart("total_chunks", String.valueOf(totalChunks)) - .addFormDataPart("chunk", file.getName() + ".part" + chunkIndex, - RequestBody.create(MediaType.parse("application/octet-stream"), buffer)) - .build(); - - // Build the request - Request request = new Request.Builder() - .url(chunkUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .post(requestBody) - .build(); - - // Execute the request - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Failed to upload chunk " + chunkIndex, e); - future.complete(false); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - try { - // Parse the response to make sure the server confirmed success - String responseBody = response.body().string(); - JSONObject jsonResponse = new JSONObject(responseBody); - - boolean success = jsonResponse.optBoolean("success", false); - int confirmedChunkIndex = jsonResponse.optInt("chunk_index", -1); - - if (success && (confirmedChunkIndex == chunkIndex || confirmedChunkIndex == -1)) { - // Update progress - long newTotal = uploadedBytes.addAndGet(currentChunkSize); - int percentComplete = (int) (newTotal * 100 / totalBytes); - - // Notify listener - if (progressListener != null) { - progressListener.onProgress(newTotal, totalBytes, percentComplete); - } - - Log.d(TAG, "Chunk " + chunkIndex + " uploaded successfully. Progress: " + - percentComplete + "%"); - future.complete(true); - } else { - Log.e(TAG, "Server reported issue with chunk " + chunkIndex + ": " + responseBody); - future.complete(false); - } - } catch (Exception e) { - Log.e(TAG, "Error parsing chunk upload response for chunk " + chunkIndex, e); - future.complete(false); - } - } else { - try { - String errorBody = response.body().string(); - Log.e(TAG, "Chunk " + chunkIndex + " upload failed with code: " + response.code() + - ", Body: " + errorBody); - } catch (Exception e) { - Log.e(TAG, "Chunk " + chunkIndex + " upload failed with code: " + response.code()); - } - future.complete(false); - } - } - }); - - } catch (IOException e) { - Log.e(TAG, "Error preparing chunk " + chunkIndex, e); - future.complete(false); - } - - return future; - } - - /** - * Verify which chunks the server has actually received - * - * @return A CompletableFuture with a Set of missing chunk indexes - */ - private CompletableFuture> verifyChunksWithServer() { - CompletableFuture> future = new CompletableFuture<>(); - Set missingChunks = new HashSet<>(); - - // Get server URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Create the verification URL - String verifyUrl = serverBaseUrl + "/upload/verify"; - - // Build the request body - RequestBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("upload_id", uploadId) - .build(); - - // Build the request - Request request = new Request.Builder() - .url(verifyUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .post(requestBody) - .build(); - - Log.d(TAG, "Verifying uploaded chunks with server..."); - - // Execute the request - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Failed to verify chunks with server", e); - // If we can't verify, assume all are ok since we've tracked them locally - future.complete(new HashSet<>()); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - try { - String responseBody = response.body().string(); - JSONObject jsonResponse = new JSONObject(responseBody); - - // Check for missing chunks - if (jsonResponse.has("missing_chunks")) { - JSONArray missingArray = jsonResponse.getJSONArray("missing_chunks"); - for (int i = 0; i < missingArray.length(); i++) { - missingChunks.add(missingArray.getInt(i)); - } - Log.d(TAG, "Server missing chunks: " + missingChunks); - } - - future.complete(missingChunks); - } catch (Exception e) { - Log.e(TAG, "Error parsing chunk verification response", e); - future.complete(new HashSet<>()); - } - } else { - try { - String errorBody = response.body().string(); - Log.e(TAG, "Chunk verification failed with code: " + response.code() + ", Body: " + errorBody); - } catch (Exception e) { - Log.e(TAG, "Chunk verification failed with code: " + response.code()); - } - - // If verification fails, try the finalization anyway with our client-side tracking - future.complete(new HashSet<>()); - } - } - }); - - return future; - } - - /** - * Retry uploading specific chunks that the server is missing - * - * @param chunkIndexes Set of chunk indexes to retry - * @return CompletableFuture indicating if all retries were successful - */ - private CompletableFuture retrySpecificChunks(Set chunkIndexes) { - CompletableFuture future = new CompletableFuture<>(); - - if (chunkIndexes.isEmpty()) { - future.complete(true); - return future; - } - - Log.d(TAG, "Retrying specific chunks: " + chunkIndexes); - - // Create a counter for completed retries - AtomicInteger completedRetries = new AtomicInteger(0); - AtomicInteger successfulRetries = new AtomicInteger(0); - int totalRetries = chunkIndexes.size(); - - // Try each chunk - for (Integer chunkIndex : chunkIndexes) { - Log.d(TAG, "Retrying upload of chunk " + chunkIndex); - - uploadChunk(chunkIndex) - .thenAccept(success -> { - int completed = completedRetries.incrementAndGet(); - - if (success) { - successfulRetries.incrementAndGet(); - Log.d(TAG, "Successfully reuploaded chunk " + chunkIndex); - } else { - Log.e(TAG, "Failed to reupload chunk " + chunkIndex); - } - - // If all retries have completed, complete the future - if (completed == totalRetries) { - boolean allSuccessful = successfulRetries.get() == totalRetries; - Log.d(TAG, "All chunk retries completed. " + successfulRetries.get() + "/" + - totalRetries + " successful"); - future.complete(allSuccessful); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Exception retrying chunk " + chunkIndex, ex); - int completed = completedRetries.incrementAndGet(); - - // If all retries have completed, complete the future - if (completed == totalRetries) { - boolean allSuccessful = successfulRetries.get() == totalRetries; - Log.d(TAG, "All chunk retries completed. " + successfulRetries.get() + "/" + - totalRetries + " successful"); - future.complete(allSuccessful); - } - return null; - }); - } - - return future; - } - - /** - * Finalize the upload on the server - * - * @return A future that completes with the upload result - */ - private CompletableFuture finalizeUpload() { - CompletableFuture future = new CompletableFuture<>(); - - // Verify all chunks are uploaded before finalizing - boolean allChunksUploaded = true; - Set missingChunks = new HashSet<>(); - - for (int i = 0; i < totalChunks; i++) { - if (!completedChunks.contains(i)) { - missingChunks.add(i); - allChunksUploaded = false; - } - } - - if (!allChunksUploaded) { - Log.e(TAG, "Cannot finalize: missing chunks " + missingChunks); - future.complete(new UploadResult("Cannot finalize: missing chunks " + missingChunks)); - return future; - } - - // Get server URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Before finalizing, verify with the server which chunks it has received - verifyChunksWithServer() - .thenAccept(serverMissingChunks -> { - if (!serverMissingChunks.isEmpty()) { - Log.e(TAG, "Server missing chunks: " + serverMissingChunks + ", reuploading missing chunks"); - - // Reset these chunks as not uploaded so they'll be retried - // Also subtract their bytes from uploadedBytes to prevent progress > 100% - for (Integer chunkIndex : serverMissingChunks) { - completedChunks.remove(chunkIndex); - - // Calculate chunk size and subtract from uploaded bytes - long start = (long) chunkIndex * chunkSize; - long end = Math.min(start + chunkSize, file.length()); - long chunkBytes = end - start; - uploadedBytes.addAndGet(-chunkBytes); - } - - // Retry uploading the missing chunks - retrySpecificChunks(serverMissingChunks) - .thenAccept(retrySuccess -> { - if (retrySuccess) { - // Now try finalizing again - Log.d(TAG, "Missing chunks reuploaded, finalizing again"); - performFinalizeRequest(future); - } else { - Log.e(TAG, "Failed to reupload missing chunks"); - future.complete(new UploadResult("Failed to reupload missing chunks")); - } - }); - } else { - // All chunks are verified by the server, proceed with finalization - performFinalizeRequest(future); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error verifying chunks with server", ex); - // Try to finalize anyway - performFinalizeRequest(future); - return null; - }); - - return future; - } - - /** - * Perform the finalize request to the server - * - * @param future The CompletableFuture to complete with the upload result - */ - private void performFinalizeRequest(CompletableFuture future) { - // Get server URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Create the finalize URL - String finalizeUrl = serverBaseUrl + "/upload/complete"; - - // Build the request body - RequestBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("upload_id", uploadId) - .addFormDataPart("filename", file.getName()) - .addFormDataPart("total_chunks", String.valueOf(totalChunks)) - .addFormDataPart("max_downloads", String.valueOf(maxDownloads)) - .addFormDataPart("expiry_hours", String.valueOf(expiryHours)) - .build(); - - // Build the request - Request request = new Request.Builder() - .url(finalizeUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .post(requestBody) - .build(); - - Log.d(TAG, "Finalizing upload..."); - - // Execute the request - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Failed to finalize upload", e); - future.complete(new UploadResult("Failed to finalize upload: " + e.getMessage())); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - try { - String responseBody = response.body().string(); - Log.d(TAG, "Server response: " + responseBody); - - // Parse JSON response to get the upload code - JSONObject jsonResponse = new JSONObject(responseBody); - String uploadCode = jsonResponse.getString("code"); - - Log.d(TAG, "Upload finalized successfully! Code: " + uploadCode); - - // Create the result - UploadResult result = new UploadResult( - uploadCode, file.getName(), maxDownloads, expiryHours); - - // Notify listener - if (progressListener != null) { - progressListener.onUploadComplete(result); - } - - future.complete(result); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse server response", e); - future.complete(new UploadResult("Failed to parse server response")); - } - } else { - try { - String errorBody = response.body().string(); - Log.e(TAG, "Finalize failed with code: " + response.code() + ", Body: " + errorBody); - - // Try to parse error body to see if there are missing chunks we can retry - try { - JSONObject errorJson = new JSONObject(errorBody); - if (errorJson.has("missing_chunks")) { - JSONArray missingArray = errorJson.getJSONArray("missing_chunks"); - Set missingChunks = new HashSet<>(); - - for (int i = 0; i < missingArray.length(); i++) { - missingChunks.add(missingArray.getInt(i)); - } - - // Automatically retry these specific chunks - if (!missingChunks.isEmpty()) { - Log.d(TAG, "Server reported missing chunks: " + missingChunks + - ". Will retry these chunks specifically."); - - // Remove these chunks from completedChunks - // Also subtract their bytes from uploadedBytes to prevent progress > 100% - for (Integer chunkIndex : missingChunks) { - completedChunks.remove(chunkIndex); - - // Calculate chunk size and subtract from uploaded bytes - long start = (long) chunkIndex * chunkSize; - long end = Math.min(start + chunkSize, file.length()); - long chunkBytes = end - start; - uploadedBytes.addAndGet(-chunkBytes); - } - - retrySpecificChunks(missingChunks) - .thenAccept(retrySuccess -> { - if (retrySuccess) { - // Try finalizing again - Log.d(TAG, "Successfully reuploaded missing chunks, retrying finalize"); - performFinalizeRequest(future); - } else { - Log.e(TAG, "Failed to reupload missing chunks, giving up"); - future.complete(new UploadResult("Failed to reupload missing chunks")); - } - }); - - // Return early as we'll complete the future after retrying - return; - } - } - } catch (JSONException e) { - // Not a JSON response or doesn't have missing_chunks key - } - - future.complete(new UploadResult("Failed to finalize upload: " + response.code())); - } catch (Exception e) { - Log.e(TAG, "Error reading finalize error response", e); - future.complete(new UploadResult("Failed to finalize upload: " + response.code())); - } - } - } - }); - } - - /** - * Pause the upload - */ - public void pauseUpload() { - if (!isPaused.getAndSet(true)) { - Log.d(TAG, "Upload paused"); - - // Notify listener - if (progressListener != null) { - progressListener.onPaused(); - } - } - } - - /** - * Resume the upload - */ - public void resumeUpload() { - if (isPaused.getAndSet(false)) { - Log.d(TAG, "Upload resumed"); - - // Notify listener - if (progressListener != null) { - progressListener.onResumed(); - } - - // Start uploading again - uploadNextChunks() - .thenAccept(isComplete -> { - if (isComplete) { - // All chunks uploaded, finalize the upload - finalizeUpload() - .thenAccept(result -> { - // Notify listener - if (progressListener != null) { - progressListener.onUploadComplete(result); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error finalizing upload", ex); - if (progressListener != null) { - progressListener.onError("Failed to finalize upload: " + ex.getMessage()); - } - return null; - }); - } else if (progressListener != null) { - progressListener.onError("Upload incomplete or cancelled"); - } - }) - .exceptionally(ex -> { - Log.e(TAG, "Error during chunk uploading", ex); - if (progressListener != null) { - progressListener.onError("Chunk upload error: " + ex.getMessage()); - } - return null; - }); - } - } - - /** - * Cancel the upload - */ - public void cancelUpload() { - isCancelled.set(true); - Log.d(TAG, "Upload cancelled"); - - // Unregister network callback - unregisterNetworkCallback(); - } - - /** - * Set up HTTP client with timeouts based on connection type - */ - private void setupHttpClient() { - int connectionTimeout = isWifiConnection ? - NetworkConstants.CONNECTION_TIMEOUT : NetworkConstants.CONNECTION_TIMEOUT / 2; - int writeTimeout = isWifiConnection ? - NetworkConstants.WRITE_TIMEOUT : NetworkConstants.WRITE_TIMEOUT / 2; - int readTimeout = isWifiConnection ? - NetworkConstants.READ_TIMEOUT : NetworkConstants.READ_TIMEOUT / 2; - - client = new OkHttpClient.Builder() - .connectTimeout(connectionTimeout, TimeUnit.SECONDS) - .writeTimeout(writeTimeout, TimeUnit.SECONDS) - .readTimeout(readTimeout, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - } - - /** - * Detect current connection type (WiFi or Mobile) - */ - private void detectConnectionType() { - NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); - isWifiConnection = activeNetwork != null && - activeNetwork.getType() == ConnectivityManager.TYPE_WIFI; - - Log.d(TAG, "Connection type detected: " + (isWifiConnection ? "WiFi" : "Mobile")); - } - - /** - * Register network callback to monitor connection changes - */ - private void registerNetworkCallback() { - // Create network callback if not already created - if (networkCallback == null) { - networkCallback = new NetworkCallback(); - } - - // Register the callback - NetworkRequest.Builder builder = new NetworkRequest.Builder(); - connectivityManager.registerNetworkCallback(builder.build(), networkCallback); - - Log.d(TAG, "Network callback registered"); - } - - /** - * Unregister network callback - */ - private void unregisterNetworkCallback() { - if (networkCallback != null) { - try { - connectivityManager.unregisterNetworkCallback(networkCallback); - Log.d(TAG, "Network callback unregistered"); - } catch (IllegalArgumentException e) { - // Callback was not registered or already unregistered - Log.w(TAG, "Failed to unregister network callback", e); - } - } - } - - /** - * Network callback for handling connection changes - */ - private class NetworkCallback extends ConnectivityManager.NetworkCallback { - @Override - public void onAvailable(Network network) { - super.onAvailable(network); - - // Check if connection type changed - boolean wasWifi = isWifiConnection; - detectConnectionType(); - - Log.d(TAG, "Network available: " + (isWifiConnection ? "WiFi" : "Mobile")); - - // If we were paused due to network loss, resume - if (isPaused.get()) { - Log.d(TAG, "Network restored, resuming upload"); - resumeUpload(); - } - - // If connection type changed, adjust settings - if (wasWifi != isWifiConnection) { - Log.d(TAG, "Connection type changed, adjusting settings"); - setupHttpClient(); - } - } - - @Override - public void onLost(Network network) { - super.onLost(network); - - Log.d(TAG, "Network lost"); - - // Pause upload to prevent failures - pauseUpload(); - } - - @Override - public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) { - super.onCapabilitiesChanged(network, capabilities); - - boolean newIsWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI); - - // If connection type changed, adjust settings - if (newIsWifi != isWifiConnection) { - isWifiConnection = newIsWifi; - Log.d(TAG, "Connection capabilities changed to: " + (isWifiConnection ? "WiFi" : "Mobile")); - setupHttpClient(); - } - } - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.kt b/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.kt new file mode 100644 index 0000000..8c3b1e4 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.kt @@ -0,0 +1,811 @@ +package com.mattintech.simplelogupload.upload + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.util.Log +import com.mattintech.simplelogupload.constant.AppConstants +import com.mattintech.simplelogupload.constant.NetworkConstants +import com.mattintech.simplelogupload.model.UploadResult +import com.mattintech.simplelogupload.util.PreferencesUtil +import kotlinx.coroutines.* +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.math.ceil +import kotlin.math.min + +/** + * Manages chunked uploads for large files using Kotlin coroutines. + * This class handles splitting a large file into chunks, uploading them, + * tracking progress, and reassembling on the server. + * + * Key improvements over Java version: + * - Uses Dispatchers.IO for all file operations to prevent ANR + * - Uses coroutines instead of CompletableFuture for cleaner async code + * - Properly handles threading to never block the main thread + */ +class ChunkedUploadManager( + context: Context, + private val file: File, + private val maxDownloads: Int, + private val expiryHours: Int +) { + companion object { + private const val TAG = AppConstants.APP_TAG + "ChunkedUploadManager" + + // Chunk size configuration + private const val WIFI_CHUNK_SIZE = 10 * 1024 * 1024 // 10MB for WiFi + private const val MOBILE_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB for mobile networks + + // Max parallel uploads + private const val WIFI_MAX_PARALLEL = 3 + private const val MOBILE_MAX_PARALLEL = 2 + } + + // Context and network + private val context: Context = context.applicationContext + private var isWifiConnection: Boolean = false + private var networkCallback: NetworkCallback? = null + private val connectivityManager: ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Upload parameters + private val chunkSize: Int + private val totalChunks: Int + private val maxParallelUploads: Int + private val totalBytes: Long = file.length() + + // Upload state + private var uploadId: String = UUID.randomUUID().toString() + private val completedChunks = mutableSetOf() + private val inProgressChunks = mutableSetOf() + private val isPaused = AtomicBoolean(false) + private val isCancelled = AtomicBoolean(false) + private val activeUploads = AtomicInteger(0) + private val uploadedBytes = AtomicLong(0) + + // Progress tracking + private var progressListener: UploadProgressListener? = null + + // OkHttpClient for uploads + private lateinit var client: OkHttpClient + + // Coroutine scope for upload operations + private val uploadScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + // Determine initial connection type + detectConnectionType() + + // Configure chunk size based on connection type + chunkSize = if (isWifiConnection) WIFI_CHUNK_SIZE else MOBILE_CHUNK_SIZE + + // Configure max parallel uploads based on connection type and user preference + val parallelEnabled = PreferencesUtil.isParallelUploadsEnabled(context) + maxParallelUploads = when { + isWifiConnection -> WIFI_MAX_PARALLEL + parallelEnabled -> MOBILE_MAX_PARALLEL + else -> 1 + } + + // Calculate total chunks + totalChunks = ceil(file.length().toDouble() / chunkSize).toInt() + + // Setup HTTP client + setupHttpClient() + + Log.d(TAG, "Created chunked upload manager for file: ${file.name}") + Log.d(TAG, "Total size: $totalBytes bytes, Chunks: $totalChunks") + Log.d(TAG, "Connection type: ${if (isWifiConnection) "WiFi" else "Mobile"}, " + + "Chunk size: $chunkSize bytes, " + + "Parallel uploads: $maxParallelUploads ${if (parallelEnabled) "(enabled)" else "(disabled)"}") + } + + /** + * Interface for tracking upload progress + */ + interface UploadProgressListener { + fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) + fun onChunkComplete(chunkIndex: Int, totalChunks: Int) + fun onUploadComplete(result: UploadResult) + fun onError(error: String) + fun onPaused() + fun onResumed() + } + + /** + * Set a listener for upload progress events + */ + fun setProgressListener(listener: UploadProgressListener) { + this.progressListener = listener + } + + /** + * Start the upload process + * Maintains CompletableFuture API for compatibility with existing code + */ + fun startUpload(): CompletableFuture { + val future = CompletableFuture() + + uploadScope.launch { + try { + // Reset state + synchronized(completedChunks) { + completedChunks.clear() + } + synchronized(inProgressChunks) { + inProgressChunks.clear() + } + uploadedBytes.set(0) + isPaused.set(false) + isCancelled.set(false) + + // Start network monitoring + withContext(Dispatchers.Main) { + registerNetworkCallback() + } + + // Initialize upload session + val sessionId = initializeUploadSession() + if (sessionId.isNullOrEmpty()) { + future.complete(UploadResult("Failed to initialize upload session")) + return@launch + } + + uploadId = sessionId + Log.d(TAG, "Upload session initialized with ID: $uploadId") + + // Upload all chunks + val isComplete = uploadAllChunks() + + if (isComplete) { + // Finalize the upload + val result = finalizeUpload() + future.complete(result) + } else { + future.complete(UploadResult("Upload incomplete or cancelled")) + } + } catch (e: Exception) { + Log.e(TAG, "Error during upload", e) + future.complete(UploadResult("Upload error: ${e.message}")) + } + } + + return future + } + + /** + * Initialize an upload session with the server + */ + private suspend fun initializeUploadSession(): String? = suspendCoroutine { continuation -> + val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context) + val clientKey = PreferencesUtil.getClientKey(context) + val initUrl = "$serverBaseUrl/upload/start" + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("filename", file.name) + .addFormDataPart("filesize", totalBytes.toString()) + .addFormDataPart("chunk_size", chunkSize.toString()) + .addFormDataPart("total_chunks", totalChunks.toString()) + .addFormDataPart("max_downloads", maxDownloads.toString()) + .addFormDataPart("expiry_hours", expiryHours.toString()) + .build() + + val request = Request.Builder() + .url(initUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build() + + Log.d(TAG, "Initializing upload session...") + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Failed to initialize upload session", e) + continuation.resume(null) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (response.isSuccessful) { + try { + val responseBody = response.body?.string() ?: "" + val jsonResponse = JSONObject(responseBody) + val sessionId = jsonResponse.getString("upload_id") + continuation.resume(sessionId) + } catch (e: JSONException) { + Log.e(TAG, "Failed to parse session initialization response", e) + continuation.resume(null) + } + } else { + Log.e(TAG, "Session initialization failed with code: ${response.code}") + continuation.resume(null) + } + } + } + }) + } + + /** + * Upload all chunks to the server + * Uses coroutines to manage parallel uploads efficiently + */ + private suspend fun uploadAllChunks(): Boolean = coroutineScope { + val deferredResults = mutableListOf>() + + while (true) { + if (isCancelled.get()) { + return@coroutineScope false + } + + if (isPaused.get()) { + delay(100) + continue + } + + // Check if all chunks are complete + synchronized(completedChunks) { + if (completedChunks.size == totalChunks) { + val allChunksPresent = (0 until totalChunks).all { it in completedChunks } + if (allChunksPresent) { + Log.d(TAG, "All chunks uploaded successfully") + return@coroutineScope true + } + } + } + + // Find next chunks to upload + val chunksToUpload = mutableListOf() + synchronized(completedChunks) { + synchronized(inProgressChunks) { + for (i in 0 until totalChunks) { + if (activeUploads.get() >= maxParallelUploads) break + if (i !in completedChunks && i !in inProgressChunks) { + chunksToUpload.add(i) + inProgressChunks.add(i) + activeUploads.incrementAndGet() + } + } + } + } + + // Launch uploads for new chunks + for (chunkIndex in chunksToUpload) { + val deferred = async { + try { + val success = uploadChunk(chunkIndex) + + synchronized(inProgressChunks) { + inProgressChunks.remove(chunkIndex) + } + activeUploads.decrementAndGet() + + if (success) { + synchronized(completedChunks) { + completedChunks.add(chunkIndex) + } + progressListener?.onChunkComplete(chunkIndex, totalChunks) + } else if (!isCancelled.get() && !isPaused.get()) { + // Retry failed chunks + Log.d(TAG, "Chunk $chunkIndex failed, will retry") + synchronized(completedChunks) { + completedChunks.remove(chunkIndex) + } + } + + success + } catch (e: Exception) { + Log.e(TAG, "Error uploading chunk $chunkIndex", e) + synchronized(inProgressChunks) { + inProgressChunks.remove(chunkIndex) + } + activeUploads.decrementAndGet() + false + } + } + deferredResults.add(deferred) + } + + // Clean up completed deferreds + deferredResults.removeAll { it.isCompleted } + + // Check if we're stalled + synchronized(completedChunks) { + synchronized(inProgressChunks) { + if (activeUploads.get() == 0 && inProgressChunks.isEmpty() && + completedChunks.size < totalChunks && !isPaused.get()) { + Log.e(TAG, "Upload stalled with ${completedChunks.size}/$totalChunks chunks completed") + return@coroutineScope false + } + } + } + + // Small delay before checking again + delay(100) + } + + @Suppress("UNREACHABLE_CODE") + false // This line is unreachable but needed for type inference + } + + /** + * Upload a single chunk to the server + * CRITICAL: Uses Dispatchers.IO for file reading to prevent ANR + */ + private suspend fun uploadChunk(chunkIndex: Int): Boolean = withContext(Dispatchers.IO) { + val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context) + val clientKey = PreferencesUtil.getClientKey(context) + val chunkUrl = "$serverBaseUrl/upload/chunk" + + try { + // Calculate chunk boundaries + val start = chunkIndex.toLong() * chunkSize + val end = min(start + chunkSize, file.length()) + val currentChunkSize = (end - start).toInt() + + Log.d(TAG, "Uploading chunk $chunkIndex ($currentChunkSize bytes)") + + // Read chunk data on IO dispatcher - THIS FIXES THE ANR! + val buffer = ByteArray(currentChunkSize) + FileInputStream(file).use { inputStream -> + inputStream.skip(start) + val bytesRead = inputStream.read(buffer) + if (bytesRead != currentChunkSize) { + Log.e(TAG, "Failed to read chunk data: expected $currentChunkSize bytes, got $bytesRead") + return@withContext false + } + } + + // Build request + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .addFormDataPart("chunk_index", chunkIndex.toString()) + .addFormDataPart("total_chunks", totalChunks.toString()) + .addFormDataPart( + "chunk", + "${file.name}.part$chunkIndex", + buffer.toRequestBody("application/octet-stream".toMediaType()) + ) + .build() + + val request = Request.Builder() + .url(chunkUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build() + + // Execute request (suspend until complete) + val response = client.newCall(request).await() + + response.use { + if (response.isSuccessful) { + try { + val responseBody = response.body?.string() ?: "" + val jsonResponse = JSONObject(responseBody) + val success = jsonResponse.optBoolean("success", false) + val confirmedChunkIndex = jsonResponse.optInt("chunk_index", -1) + + if (success && (confirmedChunkIndex == chunkIndex || confirmedChunkIndex == -1)) { + // Update progress + val newTotal = uploadedBytes.addAndGet(currentChunkSize.toLong()) + val percentComplete = (newTotal * 100 / totalBytes).toInt() + + // Notify listener on main thread + withContext(Dispatchers.Main) { + progressListener?.onProgress(newTotal, totalBytes, percentComplete) + } + + Log.d(TAG, "Chunk $chunkIndex uploaded successfully. Progress: $percentComplete%") + return@withContext true + } else { + Log.e(TAG, "Server reported issue with chunk $chunkIndex: $responseBody") + return@withContext false + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing chunk upload response for chunk $chunkIndex", e) + return@withContext false + } + } else { + val errorBody = response.body?.string() ?: "" + Log.e(TAG, "Chunk $chunkIndex upload failed with code: ${response.code}, Body: $errorBody") + return@withContext false + } + } + } catch (e: IOException) { + Log.e(TAG, "Error preparing chunk $chunkIndex", e) + return@withContext false + } + } + + /** + * Extension function to convert OkHttp Call to suspend function + */ + private suspend fun Call.await(): Response = suspendCoroutine { continuation -> + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + }) + } + + /** + * Verify which chunks the server has received + */ + private suspend fun verifyChunksWithServer(): Set = suspendCoroutine { continuation -> + val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context) + val clientKey = PreferencesUtil.getClientKey(context) + val verifyUrl = "$serverBaseUrl/upload/verify" + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .build() + + val request = Request.Builder() + .url(verifyUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build() + + Log.d(TAG, "Verifying uploaded chunks with server...") + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Failed to verify chunks with server", e) + continuation.resume(emptySet()) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (response.isSuccessful) { + try { + val responseBody = response.body?.string() ?: "" + val jsonResponse = JSONObject(responseBody) + val missingChunks = mutableSetOf() + + if (jsonResponse.has("missing_chunks")) { + val missingArray = jsonResponse.getJSONArray("missing_chunks") + for (i in 0 until missingArray.length()) { + missingChunks.add(missingArray.getInt(i)) + } + Log.d(TAG, "Server missing chunks: $missingChunks") + } + + continuation.resume(missingChunks) + } catch (e: Exception) { + Log.e(TAG, "Error parsing chunk verification response", e) + continuation.resume(emptySet()) + } + } else { + val errorBody = response.body?.string() ?: "" + Log.e(TAG, "Chunk verification failed with code: ${response.code}, Body: $errorBody") + continuation.resume(emptySet()) + } + } + } + }) + } + + /** + * Retry uploading specific chunks + */ + private suspend fun retrySpecificChunks(chunkIndexes: Set): Boolean = coroutineScope { + if (chunkIndexes.isEmpty()) return@coroutineScope true + + Log.d(TAG, "Retrying specific chunks: $chunkIndexes") + + val results = chunkIndexes.map { chunkIndex -> + async { + Log.d(TAG, "Retrying upload of chunk $chunkIndex") + val success = uploadChunk(chunkIndex) + if (success) { + Log.d(TAG, "Successfully reuploaded chunk $chunkIndex") + } else { + Log.e(TAG, "Failed to reupload chunk $chunkIndex") + } + success + } + } + + val allSuccessful = results.awaitAll().all { it } + Log.d(TAG, "All chunk retries completed. ${results.count { it.await() }}/${results.size} successful") + + return@coroutineScope allSuccessful + } + + /** + * Finalize the upload on the server + */ + private suspend fun finalizeUpload(): UploadResult { + // Verify all chunks are uploaded + val missingChunks = synchronized(completedChunks) { + (0 until totalChunks).filter { it !in completedChunks }.toSet() + } + + if (missingChunks.isNotEmpty()) { + Log.e(TAG, "Cannot finalize: missing chunks $missingChunks") + return UploadResult("Cannot finalize: missing chunks $missingChunks") + } + + // Verify with server + val serverMissingChunks = verifyChunksWithServer() + if (serverMissingChunks.isNotEmpty()) { + Log.e(TAG, "Server missing chunks: $serverMissingChunks, reuploading") + + // Subtract bytes for missing chunks + for (chunkIndex in serverMissingChunks) { + synchronized(completedChunks) { + completedChunks.remove(chunkIndex) + } + val start = chunkIndex.toLong() * chunkSize + val end = min(start + chunkSize, file.length()) + val chunkBytes = end - start + uploadedBytes.addAndGet(-chunkBytes) + } + + // Retry missing chunks + val retrySuccess = retrySpecificChunks(serverMissingChunks) + if (!retrySuccess) { + Log.e(TAG, "Failed to reupload missing chunks") + return UploadResult("Failed to reupload missing chunks") + } + Log.d(TAG, "Missing chunks reuploaded, finalizing") + } + + // Perform finalize request + return performFinalizeRequest() + } + + /** + * Perform the finalize request to the server + */ + private suspend fun performFinalizeRequest(): UploadResult = suspendCoroutine { continuation -> + val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context) + val clientKey = PreferencesUtil.getClientKey(context) + val finalizeUrl = "$serverBaseUrl/upload/complete" + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .addFormDataPart("filename", file.name) + .addFormDataPart("total_chunks", totalChunks.toString()) + .addFormDataPart("max_downloads", maxDownloads.toString()) + .addFormDataPart("expiry_hours", expiryHours.toString()) + .build() + + val request = Request.Builder() + .url(finalizeUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build() + + Log.d(TAG, "Finalizing upload...") + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Failed to finalize upload", e) + continuation.resume(UploadResult("Failed to finalize upload: ${e.message}")) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (response.isSuccessful) { + try { + val responseBody = response.body?.string() ?: "" + Log.d(TAG, "Server response: $responseBody") + + val jsonResponse = JSONObject(responseBody) + val uploadCode = jsonResponse.getString("code") + + Log.d(TAG, "Upload finalized successfully! Code: $uploadCode") + + val result = UploadResult(uploadCode, file.name, maxDownloads, expiryHours) + progressListener?.onUploadComplete(result) + + continuation.resume(result) + } catch (e: JSONException) { + Log.e(TAG, "Failed to parse server response", e) + continuation.resume(UploadResult("Failed to parse server response")) + } + } else { + val errorBody = response.body?.string() ?: "" + Log.e(TAG, "Finalize failed with code: ${response.code}, Body: $errorBody") + continuation.resume(UploadResult("Failed to finalize upload: ${response.code}")) + } + } + } + }) + } + + /** + * Pause the upload + */ + fun pauseUpload() { + if (!isPaused.getAndSet(true)) { + Log.d(TAG, "Upload paused") + progressListener?.onPaused() + } + } + + /** + * Resume the upload + * Maintains CompletableFuture API for compatibility + */ + fun resumeUpload() { + if (isPaused.getAndSet(false)) { + Log.d(TAG, "Upload resumed") + progressListener?.onResumed() + + // Launch resume in upload scope + uploadScope.launch { + try { + val isComplete = uploadAllChunks() + if (isComplete) { + val result = finalizeUpload() + withContext(Dispatchers.Main) { + progressListener?.onUploadComplete(result) + } + } else { + withContext(Dispatchers.Main) { + progressListener?.onError("Upload incomplete or cancelled") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error during chunk uploading", e) + withContext(Dispatchers.Main) { + progressListener?.onError("Chunk upload error: ${e.message}") + } + } + } + } + } + + /** + * Cancel the upload + */ + fun cancelUpload() { + isCancelled.set(true) + Log.d(TAG, "Upload cancelled") + + // Cancel all coroutines + uploadScope.cancel() + + // Unregister network callback + unregisterNetworkCallback() + } + + /** + * Setup HTTP client with timeouts + */ + private fun setupHttpClient() { + val connectionTimeout = if (isWifiConnection) + NetworkConstants.CONNECTION_TIMEOUT + else + NetworkConstants.CONNECTION_TIMEOUT / 2 + + val writeTimeout = if (isWifiConnection) + NetworkConstants.WRITE_TIMEOUT + else + NetworkConstants.WRITE_TIMEOUT / 2 + + val readTimeout = if (isWifiConnection) + NetworkConstants.READ_TIMEOUT + else + NetworkConstants.READ_TIMEOUT / 2 + + client = OkHttpClient.Builder() + .connectTimeout(connectionTimeout.toLong(), TimeUnit.SECONDS) + .writeTimeout(writeTimeout.toLong(), TimeUnit.SECONDS) + .readTimeout(readTimeout.toLong(), TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + /** + * Detect current connection type (WiFi or Mobile) + */ + @Suppress("DEPRECATION") + private fun detectConnectionType() { + val activeNetwork = connectivityManager.activeNetworkInfo + isWifiConnection = activeNetwork != null && + activeNetwork.type == ConnectivityManager.TYPE_WIFI + + Log.d(TAG, "Connection type detected: ${if (isWifiConnection) "WiFi" else "Mobile"}") + } + + /** + * Register network callback to monitor connection changes + */ + private fun registerNetworkCallback() { + if (networkCallback == null) { + networkCallback = NetworkCallback() + } + + val builder = NetworkRequest.Builder() + connectivityManager.registerNetworkCallback(builder.build(), networkCallback!!) + + Log.d(TAG, "Network callback registered") + } + + /** + * Unregister network callback + */ + private fun unregisterNetworkCallback() { + networkCallback?.let { + try { + connectivityManager.unregisterNetworkCallback(it) + Log.d(TAG, "Network callback unregistered") + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to unregister network callback", e) + } + } + } + + /** + * Network callback for handling connection changes + */ + private inner class NetworkCallback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + + val wasWifi = isWifiConnection + detectConnectionType() + + Log.d(TAG, "Network available: ${if (isWifiConnection) "WiFi" else "Mobile"}") + + // Resume if paused due to network loss + if (isPaused.get()) { + Log.d(TAG, "Network restored, resuming upload") + resumeUpload() + } + + // Adjust settings if connection type changed + if (wasWifi != isWifiConnection) { + Log.d(TAG, "Connection type changed, adjusting settings") + setupHttpClient() + } + } + + override fun onLost(network: Network) { + super.onLost(network) + Log.d(TAG, "Network lost") + pauseUpload() + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + super.onCapabilitiesChanged(network, capabilities) + + val newIsWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + + if (newIsWifi != isWifiConnection) { + isWifiConnection = newIsWifi + Log.d(TAG, "Connection capabilities changed to: ${if (isWifiConnection) "WiFi" else "Mobile"}") + setupHttpClient() + } + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java index 5f04fcb..8dda7ec 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java +++ b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java @@ -15,9 +15,10 @@ import java.io.File; public class UploadStrategySelector { private static final String TAG = AppConstants.APP_TAG + "UploadStrategy"; - // Threshold for using chunked uploads - private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi - private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile + // Threshold for using chunked uploads - matches chunk sizes + // Only use chunked upload if file is larger than chunk size + private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 10 * 1024 * 1024; // 10MB for WiFi (matches chunk size) + private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 4 * 1024 * 1024; // 4MB for mobile (matches chunk size) /** * Check if chunked uploads should be used for the given file diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java deleted file mode 100644 index 3e5355d..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java +++ /dev/null @@ -1,228 +0,0 @@ -package com.mattintech.simplelogupload.util; - -import android.util.Log; - -import com.mattintech.simplelogupload.constant.AppConstants; -import com.mattintech.simplelogupload.constant.FileConstants; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -/** - * Utility class for zipping files - */ -public class ZipUtil { - private static final String TAG = AppConstants.APP_TAG + "ZipUtil"; - private static final int BUFFER_SIZE = 8192; // 8KB buffer - - /** - * Interface for tracking zip progress - */ - public interface ZipProgressListener { - void onProgress(long bytesProcessed, long totalBytes); - void onComplete(File zipFile); - void onError(Exception e); - } - - /** - * Compresses a file into a zip archive - * - * @param sourceFile The file to compress - * @return The compressed zip file - * @throws IOException If compression fails - */ - public static File zipFile(File sourceFile) throws IOException { - return zipFile(sourceFile, null); - } - - /** - * Compresses a file into a zip archive with progress tracking - * - * @param sourceFile The file to compress - * @param listener A listener for tracking progress (can be null) - * @return The compressed zip file - * @throws IOException If compression fails - */ - public static File zipFile(File sourceFile, ZipProgressListener listener) throws IOException { - if (sourceFile == null || !sourceFile.exists()) { - String msg = "Source file does not exist: " + (sourceFile != null ? sourceFile.getAbsolutePath() : "null"); - Log.e(TAG, msg); - throw new IOException(msg); - } - - // If already a zip file, just return it - if (FileUtil.isZipFile(sourceFile)) { - Log.d(TAG, "File is already a zip file: " + sourceFile.getName()); - if (listener != null) { - listener.onComplete(sourceFile); - } - return sourceFile; - } - - // Create a zip file in the same directory - String zipFileName = sourceFile.getName() + FileConstants.ZIP_EXTENSION; - File zipFile = new File(sourceFile.getParentFile(), zipFileName); - - Log.d(TAG, "Creating zip file: " + zipFile.getAbsolutePath()); - - try (FileOutputStream fos = new FileOutputStream(zipFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); - ZipOutputStream zos = new ZipOutputStream(bos); - FileInputStream fis = new FileInputStream(sourceFile); - BufferedInputStream bis = new BufferedInputStream(fis)) { - - ZipEntry zipEntry = new ZipEntry(sourceFile.getName()); - zipEntry.setSize(sourceFile.length()); - zos.putNextEntry(zipEntry); - - byte[] buffer = new byte[BUFFER_SIZE]; - int len; - long bytesProcessed = 0; - long totalBytes = sourceFile.length(); - - while ((len = bis.read(buffer)) > 0) { - zos.write(buffer, 0, len); - bytesProcessed += len; - - if (listener != null) { - listener.onProgress(bytesProcessed, totalBytes); - } - } - - zos.closeEntry(); - - if (listener != null) { - listener.onComplete(zipFile); - } - - Log.d(TAG, "Successfully created zip file: " + zipFile.getName()); - return zipFile; - } catch (IOException e) { - Log.e(TAG, "Error creating zip file", e); - - // Clean up the partial zip file if it exists - if (zipFile.exists()) { - boolean deleted = zipFile.delete(); - if (!deleted) { - Log.w(TAG, "Failed to delete partial zip file: " + zipFile.getAbsolutePath()); - } - } - - if (listener != null) { - listener.onError(e); - } - - throw e; - } - } - - /** - * Compresses multiple files into a single zip archive - * - * @param sourceFiles List of files to compress - * @param outputDirectory Directory to store the zip file - * @param zipFileName Name of the zip file to create - * @param listener A listener for tracking progress (can be null) - * @return The compressed zip file - * @throws IOException If compression fails - */ - public static File zipFiles(List sourceFiles, File outputDirectory, String zipFileName, - ZipProgressListener listener) throws IOException { - if (sourceFiles == null || sourceFiles.isEmpty()) { - String msg = "No source files provided for zipping"; - Log.e(TAG, msg); - throw new IOException(msg); - } - - // Create output directory if it doesn't exist - if (!outputDirectory.exists()) { - if (!outputDirectory.mkdirs()) { - throw new IOException("Could not create output directory: " + outputDirectory.getAbsolutePath()); - } - } - - // Ensure the zip extension - if (!zipFileName.toLowerCase().endsWith(FileConstants.ZIP_EXTENSION)) { - zipFileName += FileConstants.ZIP_EXTENSION; - } - - // Create a zip file in the output directory - File zipFile = new File(outputDirectory, zipFileName); - - Log.d(TAG, "Creating multi-file zip: " + zipFile.getAbsolutePath() + " with " + sourceFiles.size() + " files"); - - // Calculate total size for progress reporting - long totalBytes = 0; - for (File file : sourceFiles) { - if (file.exists()) { - totalBytes += file.length(); - } - } - - try (FileOutputStream fos = new FileOutputStream(zipFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); - ZipOutputStream zos = new ZipOutputStream(bos)) { - - byte[] buffer = new byte[BUFFER_SIZE]; - long bytesProcessed = 0; - - for (File file : sourceFiles) { - if (!file.exists() || !file.isFile()) { - Log.w(TAG, "Skipping non-existent or non-file: " + file.getAbsolutePath()); - continue; - } - - try (FileInputStream fis = new FileInputStream(file); - BufferedInputStream bis = new BufferedInputStream(fis)) { - - ZipEntry zipEntry = new ZipEntry(file.getName()); - zipEntry.setSize(file.length()); - zos.putNextEntry(zipEntry); - - int len; - while ((len = bis.read(buffer)) > 0) { - zos.write(buffer, 0, len); - bytesProcessed += len; - - if (listener != null) { - listener.onProgress(bytesProcessed, totalBytes); - } - } - - zos.closeEntry(); - } - } - - if (listener != null) { - listener.onComplete(zipFile); - } - - Log.d(TAG, "Successfully created multi-file zip: " + zipFile.getName()); - return zipFile; - - } catch (IOException e) { - Log.e(TAG, "Error creating multi-file zip", e); - - // Clean up the partial zip file if it exists - if (zipFile.exists()) { - boolean deleted = zipFile.delete(); - if (!deleted) { - Log.w(TAG, "Failed to delete partial zip file: " + zipFile.getAbsolutePath()); - } - } - - if (listener != null) { - listener.onError(e); - } - - throw e; - } - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt b/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt new file mode 100644 index 0000000..45e2911 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt @@ -0,0 +1,239 @@ +package com.mattintech.simplelogupload.util + +import android.util.Log +import com.mattintech.simplelogupload.constant.AppConstants +import com.mattintech.simplelogupload.constant.FileConstants +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Utility class for zipping files using Kotlin coroutines + * CRITICAL: All file I/O operations run on Dispatchers.IO to prevent ANR + */ +object ZipUtil { + private const val TAG = AppConstants.APP_TAG + "ZipUtil" + private const val BUFFER_SIZE = 8192 // 8KB buffer + + /** + * Interface for tracking zip progress + */ + interface ZipProgressListener { + fun onProgress(bytesProcessed: Long, totalBytes: Long) + fun onComplete(zipFile: File) + fun onError(e: Exception) + } + + /** + * Compresses a file into a zip archive + * Runs on Dispatchers.IO to prevent ANR + * Checks for cancellation during compression + * + * @param sourceFile The file to compress + * @param listener A listener for tracking progress (can be null) + * @throws IOException If compression fails + */ + @JvmStatic + suspend fun zipFile(sourceFile: File, listener: ZipProgressListener? = null) { + withContext(Dispatchers.IO) { + try { + if (!sourceFile.exists()) { + val msg = "Source file does not exist: ${sourceFile.absolutePath}" + Log.e(TAG, msg) + throw IOException(msg) + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(sourceFile)) { + Log.d(TAG, "File is already a zip file: ${sourceFile.name}") + listener?.onComplete(sourceFile) + return@withContext + } + + // Create a zip file in the same directory + val zipFileName = sourceFile.name + FileConstants.ZIP_EXTENSION + val zipFile = File(sourceFile.parentFile, zipFileName) + + Log.d(TAG, "Creating zip file: ${zipFile.absolutePath}") + + try { + FileOutputStream(zipFile).use { fos -> + BufferedOutputStream(fos).use { bos -> + ZipOutputStream(bos).use { zos -> + FileInputStream(sourceFile).use { fis -> + BufferedInputStream(fis).use { bis -> + val zipEntry = ZipEntry(sourceFile.name) + zipEntry.size = sourceFile.length() + zos.putNextEntry(zipEntry) + + val buffer = ByteArray(BUFFER_SIZE) + var bytesProcessed = 0L + val totalBytes = sourceFile.length() + var len: Int + var lastProgressUpdate = 0L + + while (bis.read(buffer).also { len = it } > 0) { + // Check for cancellation + if (!isActive) { + Log.d(TAG, "Zip operation cancelled by user") + throw CancellationException("Zip operation cancelled") + } + + zos.write(buffer, 0, len) + bytesProcessed += len + + // Update progress every 100KB to avoid overwhelming UI + if (bytesProcessed - lastProgressUpdate >= 100_000 || bytesProcessed == totalBytes) { + lastProgressUpdate = bytesProcessed + Log.d(TAG, "Zip progress: $bytesProcessed / $totalBytes bytes (${(bytesProcessed * 100 / totalBytes)}%)") + listener?.onProgress(bytesProcessed, totalBytes) + } + } + + zos.closeEntry() + } + } + } + } + } + + listener?.onComplete(zipFile) + Log.d(TAG, "Successfully created zip file: ${zipFile.name}") + } catch (e: CancellationException) { + // Clean up the partial zip file + if (zipFile.exists()) { + zipFile.delete() + Log.d(TAG, "Deleted partial zip file after cancellation") + } + // Re-throw to cancel the coroutine properly + throw e + } catch (e: IOException) { + Log.e(TAG, "Error creating zip file", e) + + // Clean up the partial zip file if it exists + if (zipFile.exists()) { + val deleted = zipFile.delete() + if (!deleted) { + Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}") + } + } + + listener?.onError(e) + throw e + } + } catch (e: CancellationException) { + // Don't treat cancellation as an error - just re-throw it + Log.d(TAG, "Zip cancelled - cleaning up") + throw e + } catch (e: Exception) { + listener?.onError(e) + throw e + } + } + } + + /** + * Compresses multiple files into a single zip archive + * Runs on Dispatchers.IO to prevent ANR + * + * @param sourceFiles List of files to compress + * @param outputDirectory Directory to store the zip file + * @param zipFileName Name of the zip file to create + * @param listener A listener for tracking progress (can be null) + * @return The compressed zip file + * @throws IOException If compression fails + */ + @JvmStatic + suspend fun zipFiles( + sourceFiles: List, + outputDirectory: File, + zipFileName: String, + listener: ZipProgressListener? = null + ): File = withContext(Dispatchers.IO) { + if (sourceFiles.isEmpty()) { + val msg = "No source files provided for zipping" + Log.e(TAG, msg) + throw IOException(msg) + } + + // Create output directory if it doesn't exist + if (!outputDirectory.exists()) { + if (!outputDirectory.mkdirs()) { + throw IOException("Could not create output directory: ${outputDirectory.absolutePath}") + } + } + + // Ensure the zip extension + val finalZipFileName = if (zipFileName.lowercase().endsWith(FileConstants.ZIP_EXTENSION)) { + zipFileName + } else { + zipFileName + FileConstants.ZIP_EXTENSION + } + + // Create a zip file in the output directory + val zipFile = File(outputDirectory, finalZipFileName) + + Log.d(TAG, "Creating multi-file zip: ${zipFile.absolutePath} with ${sourceFiles.size} files") + + // Calculate total size for progress reporting + val totalBytes = sourceFiles.filter { it.exists() }.sumOf { it.length() } + + try { + FileOutputStream(zipFile).use { fos -> + BufferedOutputStream(fos).use { bos -> + ZipOutputStream(bos).use { zos -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesProcessed = 0L + + for (file in sourceFiles) { + if (!file.exists() || !file.isFile) { + Log.w(TAG, "Skipping non-existent or non-file: ${file.absolutePath}") + continue + } + + FileInputStream(file).use { fis -> + BufferedInputStream(fis).use { bis -> + val zipEntry = ZipEntry(file.name) + zipEntry.size = file.length() + zos.putNextEntry(zipEntry) + + var len: Int + while (bis.read(buffer).also { len = it } > 0) { + zos.write(buffer, 0, len) + bytesProcessed += len + + listener?.onProgress(bytesProcessed, totalBytes) + } + + zos.closeEntry() + } + } + } + } + } + } + + listener?.onComplete(zipFile) + Log.d(TAG, "Successfully created multi-file zip: ${zipFile.name}") + + return@withContext zipFile + } catch (e: IOException) { + Log.e(TAG, "Error creating multi-file zip", e) + + // Clean up the partial zip file if it exists + if (zipFile.exists()) { + val deleted = zipFile.delete() + if (!deleted) { + Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}") + } + } + + listener?.onError(e) + throw e + } + } +} 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 0d859f4..736374f 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt @@ -88,6 +88,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { uploadReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { + AppConstants.ACTION_ZIP_PROGRESS -> { + val percentComplete = intent.getIntExtra(AppConstants.EXTRA_PERCENT_COMPLETE, 0) + + _uiState.value = _uiState.value.copy( + uploadProgress = percentComplete, + uploadStatusMessage = "Compressing file: $percentComplete%" + ) + } AppConstants.ACTION_UPLOAD_PROGRESS -> { val bytesUploaded = intent.getLongExtra(AppConstants.EXTRA_BYTES_UPLOADED, 0) val totalBytes = intent.getLongExtra(AppConstants.EXTRA_TOTAL_BYTES, 0) @@ -195,6 +203,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } val filter = IntentFilter().apply { + addAction(AppConstants.ACTION_ZIP_PROGRESS) addAction(AppConstants.ACTION_UPLOAD_PROGRESS) addAction(AppConstants.ACTION_UPLOAD_COMPLETE) addAction(AppConstants.ACTION_UPLOAD_ERROR)