Fix ANR during chunked uploads and file compression

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
This commit is contained in:
2025-12-27 22:56:07 -05:00
parent 04b3bea454
commit eff3478a97
10 changed files with 1502 additions and 1547 deletions

View File

@@ -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_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; 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_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_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED";
public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED"; public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED";
public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED"; public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED";

View File

@@ -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<File> 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<File> 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<File> prepareMultipleFilesForUploadAsync(List<File> 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<File> 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());
}
}

View File

@@ -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<File> {
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<File>,
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<File> {
val future = java.util.concurrent.CompletableFuture<File>()
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<File>,
context: Context
): java.util.concurrent.CompletableFuture<File> {
val future = java.util.concurrent.CompletableFuture<File>()
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<File> {
val future = java.util.concurrent.CompletableFuture<File>()
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
}
}

View File

@@ -69,10 +69,11 @@ class FileUploadForegroundService : Service() {
// Upload state tracking // Upload state tracking
private var currentUploadManager: ChunkedUploadManager? = null private var currentUploadManager: ChunkedUploadManager? = null
private var currentStandardUploadCall: Call? = null private var currentStandardUploadCall: Call? = null
private var currentZipJob: kotlinx.coroutines.Job? = null
private var uploadState: UploadState = UploadState.IDLE private var uploadState: UploadState = UploadState.IDLE
enum class UploadState { enum class UploadState {
IDLE, UPLOADING, PAUSED, CANCELLED IDLE, PREPARING, UPLOADING, PAUSED, CANCELLED
} }
override fun onCreate() { override fun onCreate() {
@@ -170,18 +171,51 @@ class FileUploadForegroundService : Service() {
// Mark upload as in progress // Mark upload as in progress
PreferencesUtil.setUploadInProgress(this, true, file.name) 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...") 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 { FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback {
override fun onSuccess(preparedFile: File) { 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, "File prepared for upload: ${preparedFile.name}")
Log.d(TAG, "Starting upload process...") Log.d(TAG, "Starting upload process...")
uploadState = UploadState.IDLE
uploadFile(preparedFile, maxDownloads, expiryHours) uploadFile(preparedFile, maxDownloads, expiryHours)
} }
override fun onProgress(progress: Int) { override fun onProgress(progress: Int) {
Log.d(TAG, "File preparation progress: $progress%") if (uploadState == UploadState.CANCELLED) {
updateNotification("Preparing file: $progress%", progress, 100) 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) { override fun onError(e: Exception) {
@@ -580,6 +614,10 @@ class FileUploadForegroundService : Service() {
builder.clearActions() // Remove old actions builder.clearActions() // Remove old actions
when (uploadState) { when (uploadState) {
UploadState.PREPARING -> {
// Show Cancel button during file preparation/zipping
builder.addAction(createCancelAction())
}
UploadState.PAUSED -> { UploadState.PAUSED -> {
// Show Resume and Cancel buttons // Show Resume and Cancel buttons
builder.addAction(createResumeAction()) builder.addAction(createResumeAction())
@@ -696,6 +734,18 @@ class FileUploadForegroundService : Service() {
LocalBroadcastManager.getInstance(this).sendBroadcast(intent) 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 * Send a broadcast when upload is paused
*/ */
@@ -839,6 +889,9 @@ class FileUploadForegroundService : Service() {
uploadState = UploadState.CANCELLED uploadState = UploadState.CANCELLED
// Cancel file preparation (zipping) if in progress
FileController.cancelPreparation()
// Cancel chunked upload // Cancel chunked upload
currentUploadManager?.let { manager -> currentUploadManager?.let { manager ->
manager.cancelUpload() manager.cancelUpload()

View File

@@ -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<Int>()
private val inProgressChunks = mutableSetOf<Int>()
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<UploadResult> {
val future = CompletableFuture<UploadResult>()
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<Deferred<Boolean>>()
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<Int>()
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<Int> = 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<Int>()
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<Int>): 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()
}
}
}
}

View File

@@ -15,9 +15,10 @@ import java.io.File;
public class UploadStrategySelector { public class UploadStrategySelector {
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy"; private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
// Threshold for using chunked uploads // Threshold for using chunked uploads - matches chunk sizes
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi // Only use chunked upload if file is larger than chunk size
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile 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 * Check if chunked uploads should be used for the given file

View File

@@ -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<File> 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;
}
}
}

View File

@@ -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<File>,
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
}
}
}

View File

@@ -88,6 +88,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uploadReceiver = object : BroadcastReceiver() { uploadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { 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 -> { AppConstants.ACTION_UPLOAD_PROGRESS -> {
val bytesUploaded = intent.getLongExtra(AppConstants.EXTRA_BYTES_UPLOADED, 0) val bytesUploaded = intent.getLongExtra(AppConstants.EXTRA_BYTES_UPLOADED, 0)
val totalBytes = intent.getLongExtra(AppConstants.EXTRA_TOTAL_BYTES, 0) val totalBytes = intent.getLongExtra(AppConstants.EXTRA_TOTAL_BYTES, 0)
@@ -195,6 +203,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
val filter = IntentFilter().apply { val filter = IntentFilter().apply {
addAction(AppConstants.ACTION_ZIP_PROGRESS)
addAction(AppConstants.ACTION_UPLOAD_PROGRESS) addAction(AppConstants.ACTION_UPLOAD_PROGRESS)
addAction(AppConstants.ACTION_UPLOAD_COMPLETE) addAction(AppConstants.ACTION_UPLOAD_COMPLETE)
addAction(AppConstants.ACTION_UPLOAD_ERROR) addAction(AppConstants.ACTION_UPLOAD_ERROR)