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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,11 +226,15 @@ 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 {
|
||||
// Track upload parameters for result construction
|
||||
currentUploadFileName = file.name
|
||||
currentUploadMaxDownloads = maxDownloads
|
||||
currentUploadExpiryHours = expiryHours
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
showUploadParamsDialog = false
|
||||
@@ -220,38 +242,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
Log.d(TAG, "Starting upload for file: ${file.name}")
|
||||
|
||||
// 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
|
||||
}
|
||||
// Start foreground service for upload with notification
|
||||
uploadController.startUploadService(file, maxDownloads, expiryHours)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading file", e)
|
||||
Log.e(TAG, "Error starting upload service", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading file: ${e.message}"
|
||||
errorMessage = "Error starting upload: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload selected dumpstate files
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user