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:
@@ -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";
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal file
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user