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).
This commit is contained in:
2025-12-27 20:28:16 -05:00
parent 4ac3114064
commit 4557d13064
8 changed files with 780 additions and 825 deletions

View File

@@ -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<UploadResult> uploadFile(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> 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<UploadResult> performChunkedUpload(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> 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<UploadResult> performStandardUpload(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> 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<Boolean> deleteFileFromServer(String uploadCode) {
CompletableFuture<Boolean> 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;
}
}

View File

@@ -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<Boolean> {
val future = CompletableFuture<Boolean>()
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
}
}

View File

@@ -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 +
'}';
}
}

View File

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

View File

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

View File

@@ -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<UploadResult> {
val future = CompletableFuture<UploadResult>()
// 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<UploadResult> {
val future = CompletableFuture<UploadResult>()
// 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)
}
}

View File

@@ -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)
}

View File

@@ -49,6 +49,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private var uploadReceiver: BroadcastReceiver? = null 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 { init {
loadDumpstateFiles() loadDumpstateFiles()
registerUploadReceiver() registerUploadReceiver()
@@ -65,9 +70,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE) val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE)
Log.d(TAG, "Upload completed: $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( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
uploadResult = UploadResult(code ?: "UNKNOWN"), uploadResult = result,
showUploadResultDialog = true 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) { fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
viewModelScope.launch(Dispatchers.IO) { try {
try { // Track upload parameters for result construction
_uiState.value = _uiState.value.copy( currentUploadFileName = file.name
isLoading = true, currentUploadMaxDownloads = maxDownloads
showUploadParamsDialog = false 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 Log.d(TAG, "Starting upload for file: ${file.name}")
uploadController.uploadFile(file, maxDownloads, expiryHours)
.thenAccept { result -> // Start foreground service for upload with notification
if (result.isSuccess) { uploadController.startUploadService(file, maxDownloads, expiryHours)
_uiState.value = _uiState.value.copy(
isLoading = false, } catch (e: Exception) {
uploadResult = result, Log.e(TAG, "Error starting upload service", e)
showUploadResultDialog = true _uiState.value = _uiState.value.copy(
) isLoading = false,
} else { errorMessage = "Error starting upload: ${e.message}"
_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}"
)
}
} }
} }
@@ -277,32 +278,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Log.d(TAG, "Starting upload for ${files.size} dumpstate files") 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 // TODO: Implement proper multi-file ZIP and upload
val firstFile = files.first() val firstFile = files.first()
uploadController.uploadFile(firstFile, maxDownloads, expiryHours)
.thenAccept { result -> // Track upload parameters for result construction
if (result.isSuccess) { currentUploadFileName = firstFile.name
_uiState.value = _uiState.value.copy( currentUploadMaxDownloads = maxDownloads
isLoading = false, currentUploadExpiryHours = expiryHours
uploadResult = result,
showUploadResultDialog = true, uploadController.startUploadService(firstFile, maxDownloads, expiryHours)
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
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error uploading dumpstate files", e) Log.e(TAG, "Error uploading dumpstate files", e)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(