From 4557d13064d843fb6729236aba378e6d1ecbc01e Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 20:28:16 -0500 Subject: [PATCH] Refactor upload architecture and fix Android 14+ notification delay - Convert UploadController, FileUploadForegroundService, and UploadHistory from Java to Kotlin - Move all upload logic from UploadController into FileUploadForegroundService for better encapsulation - Service now directly manages ChunkedUploadManager instances instead of delegating to controller - Add FOREGROUND_SERVICE_IMMEDIATE flag to bypass Android 14+ notification display delay (up to 10s for DATA_SYNC services) - Add notification update logging for better debugging - Simplify UploadController to only handle service launching and server file deletion Fixes notification appearing late during uploads (was showing around 70% progress instead of immediately). --- .../controller/UploadController.java | 380 ------------ .../controller/UploadController.kt | 118 ++++ .../simplelogupload/model/UploadHistory.java | 109 ---- .../simplelogupload/model/UploadHistory.kt | 29 + .../service/FileUploadForegroundService.java | 273 --------- .../service/FileUploadForegroundService.kt | 555 ++++++++++++++++++ .../upload/UploadProgressCallback.kt | 30 + .../viewmodel/MainViewModel.kt | 111 ++-- 8 files changed, 780 insertions(+), 825 deletions(-) delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.kt delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.kt delete mode 100644 app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java create mode 100644 app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt create mode 100644 app/src/main/java/com/mattintech/simplelogupload/upload/UploadProgressCallback.kt diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java b/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java deleted file mode 100644 index d4cf463..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java +++ /dev/null @@ -1,380 +0,0 @@ -package com.mattintech.simplelogupload.controller; - -import android.app.Application; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import com.mattintech.simplelogupload.constant.AppConstants; -import com.mattintech.simplelogupload.constant.NetworkConstants; -import com.mattintech.simplelogupload.db.UploadHistoryRepository; -import com.mattintech.simplelogupload.model.UploadHistory; -import com.mattintech.simplelogupload.model.UploadResult; -import com.mattintech.simplelogupload.service.FileUploadForegroundService; -import com.mattintech.simplelogupload.upload.ChunkedUploadManager; -import com.mattintech.simplelogupload.upload.UploadStrategySelector; -import com.mattintech.simplelogupload.util.PreferencesUtil; - -import java.io.File; -import java.io.IOException; - -import org.json.JSONException; -import org.json.JSONObject; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -/** - * Controller for handling file uploads - */ -public class UploadController { - private static final String TAG = AppConstants.APP_TAG + "UploadController"; - - private final Context context; - private final UploadHistoryRepository repository; - - /** - * Constructor - * - * @param context The application context - */ - public UploadController(Context context) { - this.context = context.getApplicationContext(); - this.repository = new UploadHistoryRepository((Application) this.context); - } - - /** - * Start an upload using the foreground service - * - * @param file The file to upload - */ - public void startUploadService(File file) { - if (file == null || !file.exists()) { - Log.e(TAG, "Cannot start upload service with null or non-existent file"); - return; - } - - Intent serviceIntent = new Intent(context, FileUploadForegroundService.class); - serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, file.getAbsolutePath()); - serviceIntent.putExtra(FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS); - serviceIntent.putExtra(FileUploadForegroundService.EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS); - - // On Android 12+ (API 31+), we need to use startForegroundService - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { - Log.d(TAG, "Starting foreground service (Android 12+)"); - context.startForegroundService(serviceIntent); - } else { - Log.d(TAG, "Starting service (pre-Android 12)"); - context.startService(serviceIntent); - } - } - - /** - * Upload a file directly and return a future with the result - * - * @param file The file to upload - * @param maxDownloads Maximum number of downloads - * @param expiryHours Expiry time in hours - * @return CompletableFuture containing the upload result - */ - public CompletableFuture uploadFile( - File file, int maxDownloads, int expiryHours) { - CompletableFuture future = new CompletableFuture<>(); - - if (file == null || !file.exists()) { - future.complete(new UploadResult("File does not exist")); - return future; - } - - // Determine whether to use chunked upload or standard upload - boolean useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(context, file); - String networkType = UploadStrategySelector.getNetworkTypeName(context); - - Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length() + - ", Network: " + networkType + ", Chunked: " + useChunkedUpload); - - if (useChunkedUpload) { - // Use chunked upload for large files - return performChunkedUpload(file, maxDownloads, expiryHours); - } else { - // Use standard upload for smaller files - return performStandardUpload(file, maxDownloads, expiryHours); - } - } - - /** - * Perform a chunked upload for large files - * - * @param file The file to upload - * @param maxDownloads Maximum number of downloads - * @param expiryHours Expiry time in hours - * @return CompletableFuture containing the upload result - */ - private CompletableFuture performChunkedUpload( - File file, int maxDownloads, int expiryHours) { - CompletableFuture future = new CompletableFuture<>(); - - // Create chunked upload manager - ChunkedUploadManager uploadManager = new ChunkedUploadManager( - context, file, maxDownloads, expiryHours); - - // Set progress listener if needed - uploadManager.setProgressListener(new ChunkedUploadManager.UploadProgressListener() { - @Override - public void onProgress(long bytesUploaded, long totalBytes, int percentComplete) { - // This could be used to update UI or notify a service - Log.d(TAG, "Chunked upload progress: " + percentComplete + "%"); - } - - @Override - public void onChunkComplete(int chunkIndex, int totalChunks) { - Log.d(TAG, "Chunk " + chunkIndex + "/" + totalChunks + " complete"); - } - - @Override - public void onUploadComplete(UploadResult result) { - Log.d(TAG, "Chunked upload complete: " + result); - - // Save to history - if (result.isSuccess()) { - saveToHistory(result); - } - - future.complete(result); - } - - @Override - public void onError(String error) { - Log.e(TAG, "Chunked upload error: " + error); - future.complete(new UploadResult(error)); - } - - @Override - public void onPaused() { - Log.d(TAG, "Chunked upload paused"); - } - - @Override - public void onResumed() { - Log.d(TAG, "Chunked upload resumed"); - } - }); - - // Start the upload - uploadManager.startUpload() - .thenAccept(result -> { - // This is handled by the listener, but we need to catch any exceptions - }) - .exceptionally(ex -> { - Log.e(TAG, "Exception during chunked upload", ex); - future.complete(new UploadResult("Exception during upload: " + ex.getMessage())); - return null; - }); - - return future; - } - - /** - * Perform a standard (non-chunked) upload for smaller files - * - * @param file The file to upload - * @param maxDownloads Maximum number of downloads - * @param expiryHours Expiry time in hours - * @return CompletableFuture containing the upload result - */ - private CompletableFuture performStandardUpload( - File file, int maxDownloads, int expiryHours) { - CompletableFuture future = new CompletableFuture<>(); - - // Create OkHttpClient with timeout configuration - OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(NetworkConstants.CONNECTION_TIMEOUT, TimeUnit.SECONDS) - .writeTimeout(NetworkConstants.WRITE_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(NetworkConstants.READ_TIMEOUT, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - Log.d(TAG, "Using standard upload for file: " + file.getName()); - - RequestBody fileBody = RequestBody.create(file, MediaType.parse("application/octet-stream")); - MultipartBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart(NetworkConstants.PARAM_FILE, file.getName(), fileBody) - .addFormDataPart(NetworkConstants.PARAM_MAX_DOWNLOADS, String.valueOf(maxDownloads)) - .addFormDataPart(NetworkConstants.PARAM_EXPIRY_HOURS, String.valueOf(expiryHours)) - .build(); - - // Get server URL and client key from preferences - String serverUrl = PreferencesUtil.getServerUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - Log.d(TAG, "Using server URL: " + serverUrl); - Log.d(TAG, "Adding header '" + NetworkConstants.CLIENT_KEY_HEADER + "' with value: " + "********"); - - // Build request with client-key header - Request request = new Request.Builder() - .url(serverUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .post(requestBody) - .build(); - - Log.d(TAG, "Sending upload request with " + NetworkConstants.CLIENT_KEY_HEADER + " header"); - - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Upload failed", e); - future.complete(new UploadResult("Upload failed: " + e.getMessage())); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - try { - String responseBody = response.body().string(); - Log.d(TAG, "Server response: " + responseBody); - - // Parse JSON response to get the upload code - JSONObject jsonResponse = new JSONObject(responseBody); - String uploadCode = jsonResponse.getString("code"); - - Log.d(TAG, "Upload successful! Code: " + uploadCode); - - // Create the result - UploadResult result = new UploadResult( - uploadCode, file.getName(), maxDownloads, expiryHours); - - // Save to history - saveToHistory(result); - - future.complete(result); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse server response", e); - future.complete(new UploadResult("Failed to parse server response")); - } - } else { - String errorBody = response.body().string(); - String errorMsg; - - // Check for authentication errors - if (response.code() == 401 || response.code() == 403) { - errorMsg = "Authentication failed. Please check client key."; - Log.e(TAG, "Authentication error: " + response.code() + ", Body: " + errorBody); - Log.e(TAG, "Header sent: " + NetworkConstants.CLIENT_KEY_HEADER + ": ********"); - } else { - errorMsg = "Upload failed, Response code: " + response.code(); - Log.e(TAG, errorMsg + ", Body: " + errorBody); - } - - try { - JSONObject errorJson = new JSONObject(errorBody); - if (errorJson.has("error")) { - errorMsg = errorJson.getString("error"); - } - } catch (JSONException e) { - // If parsing fails, use the default error message - } - - future.complete(new UploadResult(errorMsg)); - } - } - }); - - return future; - } - - /** - * Save an upload result to the history database - * - * @param result The upload result - */ - private void saveToHistory(UploadResult result) { - if (result == null || !result.isSuccess()) { - return; - } - - UploadHistory historyEntry = new UploadHistory( - result.getFileName(), - System.currentTimeMillis(), - result.getCode(), - result.getMaxDownloads(), - result.getExpiryHours() - ); - - repository.insert(historyEntry) - .thenAccept(id -> Log.d(TAG, "Saved to history with ID: " + id)) - .exceptionally(ex -> { - Log.e(TAG, "Error saving to history", ex); - return null; - }); - } - - /** - * Delete a file from the server using its upload code - * - * @param uploadCode The upload code of the file to delete - * @return CompletableFuture containing a boolean indicating success or failure - */ - public CompletableFuture deleteFileFromServer(String uploadCode) { - CompletableFuture future = new CompletableFuture<>(); - - if (uploadCode == null || uploadCode.isEmpty()) { - Log.e(TAG, "Cannot delete file with empty upload code"); - future.complete(false); - return future; - } - - // Create OkHttpClient with timeout configuration - OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(NetworkConstants.CONNECTION_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(NetworkConstants.READ_TIMEOUT, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - // Get server base URL and client key from preferences - String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); - String clientKey = PreferencesUtil.getClientKey(context); - - // Create the delete URL - String deleteUrl = serverBaseUrl + "/delete/" + uploadCode; - Log.d(TAG, "Deleting file from server: " + deleteUrl); - - // Build request with client-key header - Request request = new Request.Builder() - .url(deleteUrl) - .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) - .delete() - .build(); - - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - Log.e(TAG, "Server delete failed", e); - future.complete(false); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - if (response.isSuccessful()) { - Log.d(TAG, "Server delete successful for code: " + uploadCode); - future.complete(true); - } else { - String errorBody = response.body().string(); - Log.e(TAG, "Server delete failed with code: " + response.code() + ", Body: " + errorBody); - future.complete(false); - } - } - }); - - return future; - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.kt b/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.kt new file mode 100644 index 0000000..10b7f16 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.kt @@ -0,0 +1,118 @@ +package com.mattintech.simplelogupload.controller + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.util.Log +import com.mattintech.simplelogupload.constant.AppConstants +import com.mattintech.simplelogupload.constant.NetworkConstants +import com.mattintech.simplelogupload.db.UploadHistoryRepository +import com.mattintech.simplelogupload.util.PreferencesUtil +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.File +import java.io.IOException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Controller for handling file uploads with progress tracking + */ +class UploadController(context: Context) { + + companion object { + private const val TAG = AppConstants.APP_TAG + "UploadController" + } + + private val context: Context = context.applicationContext + private val repository: UploadHistoryRepository = UploadHistoryRepository(this.context as Application) + + /** + * Start an upload using the foreground service + * + * @param file The file to upload + * @param maxDownloads Maximum number of downloads + * @param expiryHours Expiry time in hours + */ + fun startUploadService(file: File, maxDownloads: Int, expiryHours: Int) { + if (!file.exists()) { + Log.e(TAG, "Cannot start upload service with non-existent file") + return + } + + val serviceIntent = Intent(context, com.mattintech.simplelogupload.service.FileUploadForegroundService::class.java).apply { + putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_FILE_PATH, file.absolutePath) + putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, maxDownloads) + putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_EXPIRY_HOURS, expiryHours) + } + + // On Android 12+ (API 31+), we need to use startForegroundService + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + Log.d(TAG, "Starting foreground service (Android 12+)") + context.startForegroundService(serviceIntent) + } else { + Log.d(TAG, "Starting service (pre-Android 12)") + context.startService(serviceIntent) + } + } + + /** + * Delete a file from the server using its upload code + */ + fun deleteFileFromServer(uploadCode: String): CompletableFuture { + val future = CompletableFuture() + + if (uploadCode.isEmpty()) { + Log.e(TAG, "Cannot delete file with empty upload code") + future.complete(false) + return future + } + + // Create OkHttpClient with timeout configuration + val client = OkHttpClient.Builder() + .connectTimeout(NetworkConstants.CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS) + .readTimeout(NetworkConstants.READ_TIMEOUT.toLong(), TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + // Get server base URL and client key from preferences + val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context) + val clientKey = PreferencesUtil.getClientKey(context) + + // Create the delete URL + val deleteUrl = "$serverBaseUrl/delete/$uploadCode" + Log.d(TAG, "Deleting file from server: $deleteUrl") + + // Build request with client-key header + val request = Request.Builder() + .url(deleteUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .delete() + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Server delete failed", e) + future.complete(false) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (response.isSuccessful) { + Log.d(TAG, "Server delete successful for code: $uploadCode") + future.complete(true) + } else { + val errorBody = response.body?.string() ?: "" + Log.e(TAG, "Server delete failed with code: ${response.code}, Body: $errorBody") + future.complete(false) + } + } + } + }) + + return future + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java b/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java deleted file mode 100644 index 1604c6d..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.mattintech.simplelogupload.model; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.PrimaryKey; - -/** - * Entity class for upload history - */ -@Entity(tableName = "upload_history") -public class UploadHistory { - @PrimaryKey(autoGenerate = true) - private int id; - - @ColumnInfo(name = "file_name") - private String fileName; - - @ColumnInfo(name = "upload_time") - private long uploadTime; - - @ColumnInfo(name = "upload_code") - private String uploadCode; - - @ColumnInfo(name = "max_downloads") - private int maxDownloads; - - @ColumnInfo(name = "expiry_hours") - private int expiryHours; - - /** - * Constructor - * - * @param fileName The name of the uploaded file - * @param uploadTime The time of upload (in milliseconds) - * @param uploadCode The server-provided upload code - * @param maxDownloads The maximum number of downloads allowed - * @param expiryHours The expiry time in hours - */ - public UploadHistory(String fileName, long uploadTime, String uploadCode, int maxDownloads, int expiryHours) { - this.fileName = fileName; - this.uploadTime = uploadTime; - this.uploadCode = uploadCode; - this.maxDownloads = maxDownloads; - this.expiryHours = expiryHours; - } - - // Getters and setters - public int getId() { - return id; - } - - public void setId(int id) { - this.id = id; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public long getUploadTime() { - return uploadTime; - } - - public void setUploadTime(long uploadTime) { - this.uploadTime = uploadTime; - } - - public String getUploadCode() { - return uploadCode; - } - - public void setUploadCode(String uploadCode) { - this.uploadCode = uploadCode; - } - - public int getMaxDownloads() { - return maxDownloads; - } - - public void setMaxDownloads(int maxDownloads) { - this.maxDownloads = maxDownloads; - } - - public int getExpiryHours() { - return expiryHours; - } - - public void setExpiryHours(int expiryHours) { - this.expiryHours = expiryHours; - } - - @NonNull - @Override - public String toString() { - return "UploadHistory{" + - "id=" + id + - ", fileName='" + fileName + '\'' + - ", uploadTime=" + uploadTime + - ", uploadCode='" + uploadCode + '\'' + - ", maxDownloads=" + maxDownloads + - ", expiryHours=" + expiryHours + - '}'; - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.kt b/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.kt new file mode 100644 index 0000000..8aefafa --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.kt @@ -0,0 +1,29 @@ +package com.mattintech.simplelogupload.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity class for upload history + */ +@Entity(tableName = "upload_history") +data class UploadHistory( + @ColumnInfo(name = "file_name") + val fileName: String, + + @ColumnInfo(name = "upload_time") + val uploadTime: Long, + + @ColumnInfo(name = "upload_code") + val uploadCode: String, + + @ColumnInfo(name = "max_downloads") + val maxDownloads: Int, + + @ColumnInfo(name = "expiry_hours") + val expiryHours: Int +) { + @PrimaryKey(autoGenerate = true) + var id: Int = 0 +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java deleted file mode 100644 index 0ea222f..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java +++ /dev/null @@ -1,273 +0,0 @@ -package com.mattintech.simplelogupload.service; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ServiceInfo; -import android.os.Build; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import com.mattintech.simplelogupload.R; -import com.mattintech.simplelogupload.constant.AppConstants; -import com.mattintech.simplelogupload.constant.FileConstants; -import com.mattintech.simplelogupload.constant.NetworkConstants; -import com.mattintech.simplelogupload.controller.FileController; -import com.mattintech.simplelogupload.controller.UploadController; -import com.mattintech.simplelogupload.model.UploadResult; -import com.mattintech.simplelogupload.upload.UploadStrategySelector; -import com.mattintech.simplelogupload.ComposeMainActivity; - -import java.io.File; - -/** - * Foreground service for file uploads - */ -public class FileUploadForegroundService extends Service { - private static final String TAG = AppConstants.APP_TAG + "UploadService"; - private static final int NOTIFICATION_ID = 1; - - // Intent extras - public static final String EXTRA_FILE_PATH = "extra_file_path"; - public static final String EXTRA_MAX_DOWNLOADS = "extra_max_downloads"; - public static final String EXTRA_EXPIRY_HOURS = "extra_expiry_hours"; - - private UploadController uploadController; - private NotificationManager notificationManager; - private NotificationCompat.Builder notificationBuilder; - - @Override - public void onCreate() { - super.onCreate(); - uploadController = new UploadController(this); - notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - createNotificationChannel(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.d(TAG, "Foreground Service started."); - - // Get file path from intent - String filePath = intent.getStringExtra(EXTRA_FILE_PATH); - int maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS); - int expiryHours = intent.getIntExtra(EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS); - - // Log parameters for debugging - Log.d(TAG, "Upload parameters - Max Downloads: " + maxDownloads + ", Expiry Hours: " + expiryHours); - - // Validate expiry hours - if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) { - Log.w(TAG, "Expiry hours exceeded maximum allowed value, capping at " + NetworkConstants.MAX_EXPIRY_HOURS); - expiryHours = NetworkConstants.MAX_EXPIRY_HOURS; - } - - if (filePath == null) { - Log.e(TAG, "No file path provided"); - // Try to find the most recent dump state file - File mostRecentFile = FileController.getMostRecentDumpStateFile(); - if (mostRecentFile != null) { - filePath = mostRecentFile.getAbsolutePath(); - } else { - Log.e(TAG, "No file to upload"); - sendErrorBroadcast("No file to upload"); - stopSelf(); - return START_NOT_STICKY; - } - } - - File file = new File(filePath); - if (!file.exists()) { - Log.e(TAG, "File not found: " + filePath); - sendErrorBroadcast("File not found"); - stopSelf(); - return START_NOT_STICKY; - } - - // Create a notification and run the service in the foreground - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - // For Android 14+ (API 34+) - try { - Log.d(TAG, "Starting foreground service with DATA_SYNC type"); - int dataSync = 1; // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC value is 1 - startForeground(NOTIFICATION_ID, createInitialNotification(), dataSync); - } catch (Exception e) { - Log.e(TAG, "Error starting foreground service with type", e); - stopSelf(); - return START_NOT_STICKY; - } - } else { - // For Android 13 and below - startForeground(NOTIFICATION_ID, createInitialNotification()); - } - - // Create final copies for use in the callback - final int finalMaxDownloads = maxDownloads; - final int finalExpiryHours = expiryHours; - - // Prepare file for upload - FileController.prepareFileForUpload(file, new FileController.FilePrepareCallback() { - @Override - public void onSuccess(File preparedFile) { - Log.d(TAG, "File prepared for upload: " + preparedFile.getName()); - uploadFile(preparedFile, finalMaxDownloads, finalExpiryHours); - } - - @Override - public void onProgress(int progress) { - updateNotification("Preparing file: " + progress + "%"); - } - - @Override - public void onError(Exception e) { - Log.e(TAG, "Error preparing file", e); - sendErrorBroadcast("Error preparing file: " + e.getMessage()); - stopSelf(); - } - }); - - return START_NOT_STICKY; - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - /** - * Upload a file using the UploadController - * - * @param file The file to upload - * @param maxDownloads Maximum number of downloads - * @param expiryHours Expiry time in hours - */ - private void uploadFile(File file, int maxDownloads, int expiryHours) { - // Validate file size before upload - if (!FileController.isValidFileSize(file)) { - Log.e(TAG, "File too large: " + file.length() + " bytes (max: " + FileConstants.MAX_FILE_SIZE + " bytes)"); - sendErrorBroadcast("File size exceeds the maximum allowed size"); - stopSelf(); - return; - } - - // Update notification to show uploading - updateNotification("Uploading " + file.getName() + "..."); - - // Log network information - String networkType = UploadStrategySelector.getNetworkTypeName(this); - boolean isWifi = UploadStrategySelector.isWifiConnection(this); - boolean useChunked = UploadStrategySelector.shouldUseChunkedUpload(this, file); - - Log.d(TAG, "Starting upload - Network: " + networkType + ", Chunked: " + useChunked); - - // Update notification with network info - updateNotification("Uploading on " + networkType + - (useChunked ? " (chunked)" : "") + "..."); - - uploadController.uploadFile(file, maxDownloads, expiryHours) - .thenAccept(result -> { - if (result.isSuccess()) { - Log.d(TAG, "Upload successful! Code: " + result.getCode()); - sendSuccessBroadcast(result.getCode()); - } else { - Log.e(TAG, "Upload failed: " + result.getError()); - sendErrorBroadcast(result.getError()); - } - stopSelf(); - }) - .exceptionally(ex -> { - Log.e(TAG, "Exception during upload", ex); - sendErrorBroadcast("Error during upload: " + ex.getMessage()); - stopSelf(); - return null; - }); - } - - /** - * Create the initial notification - * - * @return The notification - */ - private Notification createInitialNotification() { - Intent notificationIntent = new Intent(this, ComposeMainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, - notificationIntent, PendingIntent.FLAG_IMMUTABLE); - - notificationBuilder = new NotificationCompat.Builder(this, AppConstants.NOTIFICATION_CHANNEL_ID) - .setContentTitle("File Upload") - .setContentText("Preparing file...") - .setSmallIcon(R.drawable.ic_notification) - .setContentIntent(pendingIntent) - .setProgress(100, 0, true) - .setOngoing(true); - - return notificationBuilder.build(); - } - - /** - * Update the notification with a new status message - * - * @param status The status message - */ - private void updateNotification(String status) { - if (notificationBuilder != null) { - notificationBuilder.setContentText(status); - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); - } - } - - /** - * Create the notification channel (required for Android 8.0+) - */ - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = "File Upload Channel"; - String description = "Notification channel for file upload"; - int importance = NotificationManager.IMPORTANCE_DEFAULT; - NotificationChannel channel = new NotificationChannel( - AppConstants.NOTIFICATION_CHANNEL_ID, name, importance); - channel.setDescription(description); - - notificationManager.createNotificationChannel(channel); - } - } - - /** - * Send a broadcast with the upload success result - * - * @param uploadCode The upload code from the server - */ - private void sendSuccessBroadcast(String uploadCode) { - Log.d(TAG, "Sending success broadcast with code: " + uploadCode); - Intent intent = new Intent(AppConstants.ACTION_UPLOAD_COMPLETE); - intent.putExtra(AppConstants.EXTRA_UPLOAD_CODE, uploadCode); - - // Send both a regular broadcast and a local broadcast to ensure delivery - sendBroadcast(intent); - LocalBroadcastManager.getInstance(this).sendBroadcast(intent); - } - - /** - * Send a broadcast with the upload error result - * - * @param errorMessage The error message - */ - private void sendErrorBroadcast(String errorMessage) { - Log.e(TAG, "Sending error broadcast: " + errorMessage); - Intent intent = new Intent(AppConstants.ACTION_UPLOAD_ERROR); - intent.putExtra(AppConstants.EXTRA_UPLOAD_ERROR, errorMessage); - - // Send both a regular broadcast and a local broadcast to ensure delivery - sendBroadcast(intent); - LocalBroadcastManager.getInstance(this).sendBroadcast(intent); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt new file mode 100644 index 0000000..43308f8 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.kt @@ -0,0 +1,555 @@ +package com.mattintech.simplelogupload.service + +import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.mattintech.simplelogupload.ComposeMainActivity +import com.mattintech.simplelogupload.R +import com.mattintech.simplelogupload.constant.AppConstants +import com.mattintech.simplelogupload.constant.FileConstants +import com.mattintech.simplelogupload.constant.NetworkConstants +import com.mattintech.simplelogupload.controller.FileController +import com.mattintech.simplelogupload.controller.UploadController +import com.mattintech.simplelogupload.db.UploadHistoryRepository +import com.mattintech.simplelogupload.model.UploadHistory +import com.mattintech.simplelogupload.model.UploadResult +import com.mattintech.simplelogupload.upload.ChunkedUploadManager +import com.mattintech.simplelogupload.upload.UploadProgressCallback +import com.mattintech.simplelogupload.upload.UploadStrategySelector +import com.mattintech.simplelogupload.util.PreferencesUtil +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Foreground service for file uploads with progress tracking + */ +class FileUploadForegroundService : Service() { + + companion object { + private const val TAG = AppConstants.APP_TAG + "UploadService" + private const val NOTIFICATION_ID = 1 + + // Intent extras + const val EXTRA_FILE_PATH = "extra_file_path" + const val EXTRA_MAX_DOWNLOADS = "extra_max_downloads" + const val EXTRA_EXPIRY_HOURS = "extra_expiry_hours" + } + + private lateinit var uploadController: UploadController + private lateinit var notificationManager: NotificationManager + private lateinit var repository: UploadHistoryRepository + private var notificationBuilder: NotificationCompat.Builder? = null + private var lastProgressUpdate: Long = 0 + + override fun onCreate() { + super.onCreate() + uploadController = UploadController(this) + repository = UploadHistoryRepository(application as Application) + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "========== Foreground Service started ==========") + + if (intent == null) { + Log.e(TAG, "Intent is null") + stopSelf() + return START_NOT_STICKY + } + + Log.d(TAG, "Intent received, extracting parameters...") + + // Get file path from intent + var filePath = intent.getStringExtra(EXTRA_FILE_PATH) + val maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS) + var expiryHours = intent.getIntExtra(EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS) + + // Log parameters for debugging + Log.d(TAG, "Upload parameters - Max Downloads: $maxDownloads, Expiry Hours: $expiryHours") + + // Validate expiry hours + if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) { + Log.w(TAG, "Expiry hours exceeded maximum allowed value, capping at ${NetworkConstants.MAX_EXPIRY_HOURS}") + expiryHours = NetworkConstants.MAX_EXPIRY_HOURS + } + + if (filePath == null) { + Log.e(TAG, "No file path provided") + // Try to find the most recent dump state file + val mostRecentFile = FileController.getMostRecentDumpStateFile() + if (mostRecentFile != null) { + filePath = mostRecentFile.absolutePath + } else { + Log.e(TAG, "No file to upload") + sendErrorBroadcast("No file to upload") + stopSelf() + return START_NOT_STICKY + } + } + + val file = File(filePath) + if (!file.exists()) { + Log.e(TAG, "File not found: $filePath") + sendErrorBroadcast("File not found") + stopSelf() + return START_NOT_STICKY + } + + // Create a notification and run the service in the foreground + Log.d(TAG, "Creating initial notification and starting foreground...") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // For Android 14+ (API 34+) + try { + Log.d(TAG, "Starting foreground service with DATA_SYNC type (Android 14+)") + val dataSync = 1 // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC value is 1 + startForeground(NOTIFICATION_ID, createInitialNotification(), dataSync) + Log.d(TAG, "Foreground service started successfully") + } catch (e: Exception) { + Log.e(TAG, "Error starting foreground service with type", e) + stopSelf() + return START_NOT_STICKY + } + } else { + // For Android 13 and below + Log.d(TAG, "Starting foreground service (Android <14)") + startForeground(NOTIFICATION_ID, createInitialNotification()) + Log.d(TAG, "Foreground service started successfully") + } + + // Prepare file for upload + Log.d(TAG, "Starting file preparation...") + FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback { + override fun onSuccess(preparedFile: File) { + Log.d(TAG, "File prepared for upload: ${preparedFile.name}") + Log.d(TAG, "Starting upload process...") + uploadFile(preparedFile, maxDownloads, expiryHours) + } + + override fun onProgress(progress: Int) { + Log.d(TAG, "File preparation progress: $progress%") + updateNotification("Preparing file: $progress%", progress, 100) + } + + override fun onError(e: Exception) { + Log.e(TAG, "Error preparing file", e) + sendErrorBroadcast("Error preparing file: ${e.message}") + stopSelf() + } + }) + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** + * Upload a file directly with progress tracking + */ + private fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) { + // Validate file size before upload + if (!FileController.isValidFileSize(file)) { + Log.e(TAG, "File too large: ${file.length()} bytes (max: ${FileConstants.MAX_FILE_SIZE} bytes)") + sendErrorBroadcast("File size exceeds the maximum allowed size") + stopSelf() + return + } + + // Determine upload strategy + val useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(this, file) + val networkType = UploadStrategySelector.getNetworkTypeName(this) + + Log.d(TAG, "Starting upload - Network: $networkType, Chunked: $useChunkedUpload, File: ${file.name}") + + // Execute chosen strategy + val uploadFuture = if (useChunkedUpload) { + performChunkedUpload(file, maxDownloads, expiryHours) + } else { + performStandardUpload(file, maxDownloads, expiryHours) + } + + // Handle completion + uploadFuture.thenAccept { result -> + if (result.isSuccess) { + Log.d(TAG, "Upload successful! Code: ${result.code}") + updateNotification("Upload complete!", 100, 100, isComplete = true) + sendSuccessBroadcast(result.code) + } else { + Log.e(TAG, "Upload failed: ${result.error}") + updateNotification("Upload failed: ${result.error}", -1, 100, isComplete = true) + sendErrorBroadcast(result.error) + } + + // Give user a moment to see the final status, then stop service + Handler(Looper.getMainLooper()).postDelayed({ + stopSelf() + }, 3000) + }.exceptionally { ex -> + Log.e(TAG, "Exception during upload", ex) + updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true) + sendErrorBroadcast("Error during upload: ${ex.message}") + + // Give user a moment to see the error, then stop service + Handler(Looper.getMainLooper()).postDelayed({ + stopSelf() + }, 3000) + null + } + } + + /** + * Perform a chunked upload for large files + */ + private fun performChunkedUpload( + file: File, + maxDownloads: Int, + expiryHours: Int + ): CompletableFuture { + val future = CompletableFuture() + + // CRITICAL: Show session initialization progress IMMEDIATELY + updateNotification("Initializing upload session...", -1, 100) + + // Create chunked upload manager directly + val uploadManager = ChunkedUploadManager(this, file, maxDownloads, expiryHours) + + // Set progress listener with direct notification updates + uploadManager.setProgressListener(object : ChunkedUploadManager.UploadProgressListener { + override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) { + // Direct notification update - no intermediate callbacks + val currentTime = System.currentTimeMillis() + if (currentTime - lastProgressUpdate >= 500 || percentComplete == 100) { + lastProgressUpdate = currentTime + + val uploadedMB = bytesUploaded / (1024f * 1024f) + val totalMB = totalBytes / (1024f * 1024f) + val statusText = String.format( + "Uploading: %.1f / %.1f MB (%d%%)", + uploadedMB, totalMB, percentComplete + ) + + updateNotification(statusText, percentComplete, 100) + Log.d(TAG, "Upload progress: $percentComplete% ($uploadedMB MB / $totalMB MB)") + } + } + + override fun onChunkComplete(chunkIndex: Int, totalChunks: Int) { + Log.d(TAG, "Chunk $chunkIndex/$totalChunks complete") + } + + override fun onUploadComplete(result: UploadResult) { + Log.d(TAG, "Chunked upload complete: $result") + + // Save to history directly if successful + if (result.isSuccess) { + saveToHistory(result) + } + + future.complete(result) + } + + override fun onError(error: String) { + Log.e(TAG, "Chunked upload error: $error") + future.complete(UploadResult(error)) + } + + override fun onPaused() { + Log.d(TAG, "Chunked upload paused") + updateNotification("Upload paused", -1, 100) + } + + override fun onResumed() { + Log.d(TAG, "Chunked upload resumed") + updateNotification("Resuming upload...", -1, 100) + } + }) + + // Start the upload - this triggers session initialization + uploadManager.startUpload() + .exceptionally { ex -> + Log.e(TAG, "Exception during chunked upload", ex) + future.complete(UploadResult("Exception during upload: ${ex.message}")) + null + } + + return future + } + + /** + * Perform a standard (non-chunked) upload for smaller files + */ + private fun performStandardUpload( + file: File, + maxDownloads: Int, + expiryHours: Int + ): CompletableFuture { + val future = CompletableFuture() + + // Update notification immediately + updateNotification("Uploading ${file.name}...", 0, 100) + + // Create OkHttpClient with timeout configuration + val client = OkHttpClient.Builder() + .connectTimeout(NetworkConstants.CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS) + .writeTimeout(NetworkConstants.WRITE_TIMEOUT.toLong(), TimeUnit.SECONDS) + .readTimeout(NetworkConstants.READ_TIMEOUT.toLong(), TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + Log.d(TAG, "Using standard upload for file: ${file.name}") + + val fileBody = file.asRequestBody("application/octet-stream".toMediaTypeOrNull()) + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart(NetworkConstants.PARAM_FILE, file.name, fileBody) + .addFormDataPart(NetworkConstants.PARAM_MAX_DOWNLOADS, maxDownloads.toString()) + .addFormDataPart(NetworkConstants.PARAM_EXPIRY_HOURS, expiryHours.toString()) + .build() + + // Get server URL and client key from preferences + val serverUrl = PreferencesUtil.getServerUrl(this) + val clientKey = PreferencesUtil.getClientKey(this) + + Log.d(TAG, "Using server URL: $serverUrl") + + // Build request with client-key header + val request = Request.Builder() + .url(serverUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build() + + Log.d(TAG, "Sending upload request with ${NetworkConstants.CLIENT_KEY_HEADER} header") + + // Show initial progress + updateNotification("Uploading ${file.name}...", 0, 100) + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Upload failed", e) + future.complete(UploadResult("Upload failed: ${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") + + // Update progress to 100% on success + updateNotification("Upload complete!", 100, 100) + + // Parse JSON response to get the upload code + val jsonResponse = JSONObject(responseBody) + val uploadCode = jsonResponse.getString("code") + + Log.d(TAG, "Upload successful! Code: $uploadCode") + + // Create the result + val result = UploadResult( + uploadCode, + file.name, + maxDownloads, + expiryHours + ) + + // Save to history + saveToHistory(result) + + future.complete(result) + } catch (e: JSONException) { + Log.e(TAG, "Failed to parse server response", e) + future.complete(UploadResult("Failed to parse server response")) + } + } else { + val errorBody = response.body?.string() ?: "" + val errorMsg = when (response.code) { + 401, 403 -> { + Log.e(TAG, "Authentication error: ${response.code}, Body: $errorBody") + "Authentication failed. Please check client key." + } + else -> { + Log.e(TAG, "Upload failed: ${response.code}, Body: $errorBody") + "Upload failed, Response code: ${response.code}" + } + } + + // Try to extract error message from JSON + val finalErrorMsg = try { + val errorJson = JSONObject(errorBody) + errorJson.optString("error", errorMsg) + } catch (e: JSONException) { + errorMsg + } + + future.complete(UploadResult(finalErrorMsg)) + } + } + } + }) + + return future + } + + /** + * Save an upload result to the history database + */ + private fun saveToHistory(result: UploadResult) { + if (!result.isSuccess) { + return + } + + val historyEntry = UploadHistory( + fileName = result.fileName, + uploadTime = System.currentTimeMillis(), + uploadCode = result.code, + maxDownloads = result.maxDownloads, + expiryHours = result.expiryHours + ) + + repository.insert(historyEntry) + .thenAccept { id -> Log.d(TAG, "Saved to history with ID: $id") } + .exceptionally { ex -> + Log.e(TAG, "Error saving to history", ex) + null + } + } + + /** + * Create the initial notification + */ + private fun createInitialNotification(): Notification { + val notificationIntent = Intent(this, ComposeMainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(this, AppConstants.NOTIFICATION_CHANNEL_ID) + .setContentTitle("File Upload") + .setContentText("Preparing file...") + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(pendingIntent) + .setProgress(100, 0, true) // Start with indeterminate progress + .setOngoing(true) + .setOnlyAlertOnce(true) // Don't alert on every progress update + .setPriority(NotificationCompat.PRIORITY_HIGH) // High priority for better visibility + .apply { + // Force immediate notification display on Android 14+ (prevents system delay) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + } + } + + notificationBuilder = builder + return builder.build() + } + + /** + * Update the notification with progress + * + * @param status The status message + * @param progress Current progress value (-1 for indeterminate) + * @param max Maximum progress value + * @param isComplete Whether the upload is complete (makes notification dismissible) + */ + private fun updateNotification(status: String, progress: Int = -1, max: Int = 100, isComplete: Boolean = false) { + val builder = notificationBuilder ?: return + + Log.d(TAG, "Updating notification: $status (progress: $progress/$max)") + + builder.setContentText(status) + + if (progress >= 0) { + // Determinate progress + builder.setProgress(max, progress, false) + } else { + // Indeterminate progress + builder.setProgress(max, 0, true) + } + + // When complete, make notification dismissible and remove progress bar + if (isComplete) { + builder.setOngoing(false) + .setProgress(0, 0, false) // Remove progress bar + .setAutoCancel(true) // Allow swipe to dismiss + } + + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + /** + * Create the notification channel (required for Android 8.0+) + */ + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "File Upload Channel" + val description = "Notification channel for file upload progress" + val importance = NotificationManager.IMPORTANCE_HIGH // Changed to HIGH for better visibility + val channel = NotificationChannel( + AppConstants.NOTIFICATION_CHANNEL_ID, + name, + importance + ).apply { + this.description = description + setShowBadge(true) + enableVibration(false) // Don't vibrate on progress updates + enableLights(false) // Don't flash lights on progress updates + } + + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Send a broadcast with the upload success result + */ + private fun sendSuccessBroadcast(uploadCode: String) { + Log.d(TAG, "Sending success broadcast with code: $uploadCode") + val intent = Intent(AppConstants.ACTION_UPLOAD_COMPLETE).apply { + putExtra(AppConstants.EXTRA_UPLOAD_CODE, uploadCode) + } + + // Send both a regular broadcast and a local broadcast to ensure delivery + sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + /** + * Send a broadcast with the upload error result + */ + private fun sendErrorBroadcast(errorMessage: String) { + Log.e(TAG, "Sending error broadcast: $errorMessage") + val intent = Intent(AppConstants.ACTION_UPLOAD_ERROR).apply { + putExtra(AppConstants.EXTRA_UPLOAD_ERROR, errorMessage) + } + + // Send both a regular broadcast and a local broadcast to ensure delivery + sendBroadcast(intent) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/UploadProgressCallback.kt b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadProgressCallback.kt new file mode 100644 index 0000000..83498f9 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadProgressCallback.kt @@ -0,0 +1,30 @@ +package com.mattintech.simplelogupload.upload + +/** + * Callback interface for tracking upload progress + */ +interface UploadProgressCallback { + /** + * Called when upload progress changes + * + * @param bytesUploaded Number of bytes uploaded so far + * @param totalBytes Total number of bytes to upload + * @param percentComplete Percentage complete (0-100) + */ + fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) + + /** + * Called when a chunk upload completes (for chunked uploads) + * + * @param chunkIndex Index of the completed chunk + * @param totalChunks Total number of chunks + */ + fun onChunkComplete(chunkIndex: Int, totalChunks: Int) + + /** + * Called for general status updates + * + * @param status Status message + */ + fun onStatusUpdate(status: String) +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt index 02e4f13..30f26bf 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt @@ -49,6 +49,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private var uploadReceiver: BroadcastReceiver? = null + // Track current upload parameters for result construction + private var currentUploadFileName: String? = null + private var currentUploadMaxDownloads: Int = 0 + private var currentUploadExpiryHours: Int = 0 + init { loadDumpstateFiles() registerUploadReceiver() @@ -65,9 +70,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE) Log.d(TAG, "Upload completed: $code") + // Create proper success UploadResult with tracked parameters + val result = if (code != null && currentUploadFileName != null) { + UploadResult( + code, + currentUploadFileName!!, + currentUploadMaxDownloads, + currentUploadExpiryHours + ) + } else { + // Fallback for error case + UploadResult("Upload completed but code is missing") + } + _uiState.value = _uiState.value.copy( isLoading = false, - uploadResult = UploadResult(code ?: "UNKNOWN"), + uploadResult = result, showUploadResultDialog = true ) } @@ -208,48 +226,31 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } /** - * Upload a general file + * Upload a general file using foreground service */ fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) { - viewModelScope.launch(Dispatchers.IO) { - try { - _uiState.value = _uiState.value.copy( - isLoading = true, - showUploadParamsDialog = false - ) + try { + // Track upload parameters for result construction + currentUploadFileName = file.name + currentUploadMaxDownloads = maxDownloads + currentUploadExpiryHours = expiryHours - Log.d(TAG, "Starting upload for file: ${file.name}") + _uiState.value = _uiState.value.copy( + isLoading = true, + showUploadParamsDialog = false + ) - // Upload file and handle result - uploadController.uploadFile(file, maxDownloads, expiryHours) - .thenAccept { result -> - if (result.isSuccess) { - _uiState.value = _uiState.value.copy( - isLoading = false, - uploadResult = result, - showUploadResultDialog = true - ) - } else { - _uiState.value = _uiState.value.copy( - isLoading = false, - errorMessage = result.error ?: "Upload failed" - ) - } - } - .exceptionally { ex -> - _uiState.value = _uiState.value.copy( - isLoading = false, - errorMessage = "Error uploading file: ${ex.message}" - ) - null - } - } catch (e: Exception) { - Log.e(TAG, "Error uploading file", e) - _uiState.value = _uiState.value.copy( - isLoading = false, - errorMessage = "Error uploading file: ${e.message}" - ) - } + Log.d(TAG, "Starting upload for file: ${file.name}") + + // Start foreground service for upload with notification + uploadController.startUploadService(file, maxDownloads, expiryHours) + + } catch (e: Exception) { + Log.e(TAG, "Error starting upload service", e) + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "Error starting upload: ${e.message}" + ) } } @@ -277,32 +278,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { Log.d(TAG, "Starting upload for ${files.size} dumpstate files") - // For now, upload the first file + // For now, upload the first file using foreground service // TODO: Implement proper multi-file ZIP and upload val firstFile = files.first() - uploadController.uploadFile(firstFile, maxDownloads, expiryHours) - .thenAccept { result -> - if (result.isSuccess) { - _uiState.value = _uiState.value.copy( - isLoading = false, - uploadResult = result, - showUploadResultDialog = true, - selectedDumpstateIndices = emptySet() - ) - } else { - _uiState.value = _uiState.value.copy( - isLoading = false, - errorMessage = result.error ?: "Upload failed" - ) - } - } - .exceptionally { ex -> - _uiState.value = _uiState.value.copy( - isLoading = false, - errorMessage = "Error uploading files: ${ex.message}" - ) - null - } + + // Track upload parameters for result construction + currentUploadFileName = firstFile.name + currentUploadMaxDownloads = maxDownloads + currentUploadExpiryHours = expiryHours + + uploadController.startUploadService(firstFile, maxDownloads, expiryHours) } catch (e: Exception) { Log.e(TAG, "Error uploading dumpstate files", e) _uiState.value = _uiState.value.copy(