diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..58ab402 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,66 @@ +# SimpleLogUpload Project Documentation + +## Overview + +SimpleLogUpload is an Android application designed to upload log files to a server. It provides both standard and chunked upload capabilities to handle files of various sizes across different network conditions. + +## Documentation Structure + +This project follows specific documentation conventions: + +1. **Main documentation**: + - `README.md` - General project information and setup instructions + - `PROJECT.md` (this file) - Project structure and documentation guidelines + +2. **Feature documentation**: + - Each feature must have its own dedicated Markdown file in the `./docs` folder + - Feature documentation should thoroughly explain the implementation, usage, and any special considerations + +3. **Task tracking**: + - `TASK.md` - Contains ongoing tasks and development roadmap + +## Documentation Requirements + +### Feature Documentation Rule + +**IMPORTANT**: For each new feature added to the project, a corresponding Markdown file must be created in the `./docs` folder. This file should: + +1. Have a clear, descriptive filename (e.g., `FEATURE_NAME.md`) +2. Contain comprehensive documentation including: + - Feature overview and purpose + - Technical implementation details + - API descriptions (if applicable) + - Usage examples + - Configuration options + - Testing considerations + +### Current Feature Documentation + +The current feature documentation includes: + +- `docs/CHUNKED_UPLOAD.md` - Details on the chunked upload implementation +- `docs/LARGE_UPLOAD.md` - Analysis and improvements for large file uploads +- `docs/RELEASE_PROCESS.md` - Guidelines for the release process + +## Maintaining Documentation + +When modifying or extending existing features, ensure the corresponding documentation is updated to reflect the changes. Keep all documentation accurate and up-to-date with the current implementation. + +## Adding New Documentation + +To add documentation for a new feature: + +1. Create a new Markdown file in the `./docs` folder +2. Follow the naming convention `FEATURE_NAME.md` +3. Use clear sections and subsections for easy navigation +4. Include code examples where appropriate +5. Add links to the new documentation in other relevant documents + +## Documentation Standards + +All documentation should: +- Be clear and concise +- Include code examples where applicable +- Explain the "why" as well as the "how" +- Be kept up-to-date with code changes +- Follow a consistent format and style diff --git a/README.md b/README.md index 51d87f6..868db3a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,15 @@ Note - this requires that you leverage SimpleFileUpload-Server - Upload history filtering and search - QR code generation for quick sharing +## Documentation + +Detailed documentation about specific features and processes can be found in the `docs/` directory: + +- [Project Documentation](PROJECT.md) - Project structure and documentation guidelines +- [Chunked Upload](docs/CHUNKED_UPLOAD.md) - Implementation of chunked uploads for large files +- [Large File Upload Analysis](docs/LARGE_UPLOAD.md) - Analysis and improvements for large file uploads +- [Release Process](docs/RELEASE_PROCESS.md) - Detailed release process guidelines + ## Development and Releases ### Automated Releases with GitHub Actions diff --git a/TASK.md b/TASK.md index 9683050..8fed22b 100644 --- a/TASK.md +++ b/TASK.md @@ -84,3 +84,13 @@ - [x] Multi-Dumpstate upload - [ ] Dumpstate cleanup after successful upload - [ ] Provide steps of how to collect a dumpstate and download the server + +## 🔄 Chunked Upload Implementation +- [x] Implement basic chunked upload system for large files (>100MB) +- [x] Add network type detection and adaptive settings for WiFi vs. Mobile +- [x] Implement resume capability for interrupted uploads +- [x] Add parallel chunk uploading for WiFi connections +- [x] Create network state monitoring to handle connection changes +- [x] Implement progressive backoff and retry strategy +- [x] Add enhanced progress tracking for chunks +- [x] Test on both WiFi and 5G with various file sizes diff --git a/app/build.gradle b/app/build.gradle index 43f6656..1bebb51 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,6 +56,9 @@ dependencies { implementation 'com.android.support:support-annotations:28.0.0' implementation 'androidx.palette:palette:1.0.0' + // QR Code scanning + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' + // Room def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5f6f5f..212ac86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,11 +3,13 @@ package="com.mattintech.simplelogupload"> + + = 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); + } } /** @@ -86,6 +95,106 @@ public class UploadController { return future; } + // Determine whether to use chunked upload or standard upload + boolean useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(context, file); + String networkType = UploadStrategySelector.getNetworkTypeName(context); + + Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length() + + ", Network: " + networkType + ", Chunked: " + useChunkedUpload); + + if (useChunkedUpload) { + // Use chunked upload for large files + return performChunkedUpload(file, maxDownloads, expiryHours); + } else { + // Use standard upload for smaller files + return performStandardUpload(file, maxDownloads, expiryHours); + } + } + + /** + * Perform a chunked upload for large files + * + * @param file The file to upload + * @param maxDownloads Maximum number of downloads + * @param expiryHours Expiry time in hours + * @return CompletableFuture containing the upload result + */ + private CompletableFuture performChunkedUpload( + File file, int maxDownloads, int expiryHours) { + CompletableFuture future = new CompletableFuture<>(); + + // Create chunked upload manager + ChunkedUploadManager uploadManager = new ChunkedUploadManager( + context, file, maxDownloads, expiryHours); + + // Set progress listener if needed + uploadManager.setProgressListener(new ChunkedUploadManager.UploadProgressListener() { + @Override + public void onProgress(long bytesUploaded, long totalBytes, int percentComplete) { + // This could be used to update UI or notify a service + Log.d(TAG, "Chunked upload progress: " + percentComplete + "%"); + } + + @Override + public void onChunkComplete(int chunkIndex, int totalChunks) { + Log.d(TAG, "Chunk " + chunkIndex + "/" + totalChunks + " complete"); + } + + @Override + public void onUploadComplete(UploadResult result) { + Log.d(TAG, "Chunked upload complete: " + result); + + // Save to history + if (result.isSuccess()) { + saveToHistory(result); + } + + future.complete(result); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Chunked upload error: " + error); + future.complete(new UploadResult(error)); + } + + @Override + public void onPaused() { + Log.d(TAG, "Chunked upload paused"); + } + + @Override + public void onResumed() { + Log.d(TAG, "Chunked upload resumed"); + } + }); + + // Start the upload + uploadManager.startUpload() + .thenAccept(result -> { + // This is handled by the listener, but we need to catch any exceptions + }) + .exceptionally(ex -> { + Log.e(TAG, "Exception during chunked upload", ex); + future.complete(new UploadResult("Exception during upload: " + ex.getMessage())); + return null; + }); + + return future; + } + + /** + * Perform a standard (non-chunked) upload for smaller files + * + * @param file The file to upload + * @param maxDownloads Maximum number of downloads + * @param expiryHours Expiry time in hours + * @return CompletableFuture containing the upload result + */ + private CompletableFuture performStandardUpload( + File file, int maxDownloads, int expiryHours) { + CompletableFuture future = new CompletableFuture<>(); + // Create OkHttpClient with timeout configuration OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(NetworkConstants.CONNECTION_TIMEOUT, TimeUnit.SECONDS) @@ -94,7 +203,7 @@ public class UploadController { .retryOnConnectionFailure(true) .build(); - Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length()); + 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() diff --git a/app/src/main/java/com/mattintech/simplelogupload/model/QrServerConfig.java b/app/src/main/java/com/mattintech/simplelogupload/model/QrServerConfig.java new file mode 100644 index 0000000..5a58c71 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/model/QrServerConfig.java @@ -0,0 +1,88 @@ +package com.mattintech.simplelogupload.model; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Model class for QR code server configuration + */ +public class QrServerConfig { + private final String server; + private final int port; + private final String key; + + private QrServerConfig(String server, int port, String key) { + this.server = server; + this.port = port; + this.key = key; + } + + public String getServer() { + return server; + } + + public int getPort() { + return port; + } + + public String getKey() { + return key; + } + + /** + * Parse QR code JSON data + * + * @param qrData The QR code string data + * @return QrServerConfig object + * @throws QrConfigException if parsing fails or data is invalid + */ + public static QrServerConfig fromQrData(String qrData) throws QrConfigException { + if (qrData == null || qrData.trim().isEmpty()) { + throw new QrConfigException("QR code data is empty"); + } + + try { + JSONObject json = new JSONObject(qrData); + + // Extract fields + if (!json.has("server")) { + throw new QrConfigException("Missing 'server' field in QR code"); + } + if (!json.has("port")) { + throw new QrConfigException("Missing 'port' field in QR code"); + } + if (!json.has("key")) { + throw new QrConfigException("Missing 'key' field in QR code"); + } + + String server = json.getString("server"); + int port = json.getInt("port"); + String key = json.getString("key"); + + // Validate + if (server.trim().isEmpty()) { + throw new QrConfigException("Server address cannot be empty"); + } + if (port <= 0 || port > 65535) { + throw new QrConfigException("Port must be between 1 and 65535"); + } + if (key.trim().isEmpty()) { + throw new QrConfigException("Client key cannot be empty"); + } + + return new QrServerConfig(server.trim(), port, key.trim()); + + } catch (JSONException e) { + throw new QrConfigException("Invalid QR code format: " + e.getMessage()); + } + } + + /** + * Custom exception for QR configuration errors + */ + public static class QrConfigException extends Exception { + public QrConfigException(String message) { + super(message); + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java index 84d3804..3521751 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java @@ -23,6 +23,7 @@ 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.view.MainActivity; import java.io.File; @@ -92,9 +93,17 @@ public class FileUploadForegroundService extends Service { } // Create a notification and run the service in the foreground - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // For Android 14+ (API 34+) - startForeground(NOTIFICATION_ID, createInitialNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + 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()); @@ -153,7 +162,17 @@ public class FileUploadForegroundService extends Service { // Update notification to show uploading updateNotification("Uploading " + file.getName() + "..."); - // Upload the file + // 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()) { @@ -251,4 +270,4 @@ public class FileUploadForegroundService extends Service { sendBroadcast(intent); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java b/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java new file mode 100644 index 0000000..3e84155 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/upload/ChunkedUploadManager.java @@ -0,0 +1,1025 @@ +package com.mattintech.simplelogupload.upload; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.util.Log; + +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.constant.NetworkConstants; +import com.mattintech.simplelogupload.model.UploadResult; +import com.mattintech.simplelogupload.util.PreferencesUtil; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +/** + * Manages chunked uploads for large files. + * This class handles splitting a large file into chunks, uploading them, + * tracking progress, and reassembling on the server. + */ +public class ChunkedUploadManager { + private static final String TAG = AppConstants.APP_TAG + "ChunkedUploadManager"; + + // Chunk size configuration + private static final int WIFI_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB for WiFi + private static final int MOBILE_CHUNK_SIZE = 2 * 1024 * 1024; // 2MB for mobile networks + private static final int DEFAULT_CHUNK_SIZE = WIFI_CHUNK_SIZE; + + // Max parallel uploads + private static final int WIFI_MAX_PARALLEL = 3; + private static final int MOBILE_MAX_PARALLEL = 1; + + // Retry configuration + private static final int MAX_RETRY_ATTEMPTS = 3; + private static final int BASE_RETRY_DELAY_MS = 1000; + + // Context and network + private final Context context; + private boolean isWifiConnection; + private NetworkCallback networkCallback; + private final ConnectivityManager connectivityManager; + + // Upload parameters + private final File file; + private final int maxDownloads; + private final int expiryHours; + private final int chunkSize; + private final int totalChunks; + private final int maxParallelUploads; + + // Upload state + private String uploadId; + private final Set completedChunks = new HashSet<>(); + private final Set inProgressChunks = new HashSet<>(); + private final AtomicBoolean isPaused = new AtomicBoolean(false); + private final AtomicBoolean isCancelled = new AtomicBoolean(false); + private final AtomicInteger activeUploads = new AtomicInteger(0); + private final AtomicLong uploadedBytes = new AtomicLong(0); + + // Progress tracking + private UploadProgressListener progressListener; + private final long totalBytes; + + // OkHttpClient for uploads + private OkHttpClient client; + + /** + * Interface for tracking upload progress + */ + public interface UploadProgressListener { + void onProgress(long bytesUploaded, long totalBytes, int percentComplete); + void onChunkComplete(int chunkIndex, int totalChunks); + void onUploadComplete(UploadResult result); + void onError(String error); + void onPaused(); + void onResumed(); + } + + /** + * Create a new chunked upload manager + * + * @param context The application context + * @param file The file to upload + * @param maxDownloads Maximum number of downloads + * @param expiryHours Expiry time in hours + */ + public ChunkedUploadManager(Context context, File file, int maxDownloads, int expiryHours) { + this.context = context.getApplicationContext(); + this.file = file; + this.maxDownloads = maxDownloads; + this.expiryHours = expiryHours; + this.totalBytes = file.length(); + + // Set up connectivity manager for network monitoring + this.connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + + // Determine initial connection type + detectConnectionType(); + + // Configure chunk size based on connection type + this.chunkSize = isWifiConnection ? WIFI_CHUNK_SIZE : MOBILE_CHUNK_SIZE; + + // Configure max parallel uploads based on connection type + this.maxParallelUploads = isWifiConnection ? WIFI_MAX_PARALLEL : MOBILE_MAX_PARALLEL; + + // Calculate total chunks + this.totalChunks = (int) Math.ceil((double) file.length() / chunkSize); + + // Setup HTTP client with appropriate timeouts based on connection type + setupHttpClient(); + + // Generate upload ID + this.uploadId = UUID.randomUUID().toString(); + + Log.d(TAG, "Created chunked upload manager for file: " + file.getName()); + Log.d(TAG, "Total size: " + totalBytes + " bytes, Chunks: " + totalChunks); + Log.d(TAG, "Connection type: " + (isWifiConnection ? "WiFi" : "Mobile") + + ", Chunk size: " + chunkSize + " bytes"); + } + + /** + * Set a listener for upload progress events + * + * @param listener The progress listener + */ + public void setProgressListener(UploadProgressListener listener) { + this.progressListener = listener; + } + + /** + * Start the upload process + * + * @return A CompletableFuture that completes when the upload is done + */ + public CompletableFuture startUpload() { + CompletableFuture resultFuture = new CompletableFuture<>(); + + // Reset state + completedChunks.clear(); + inProgressChunks.clear(); + uploadedBytes.set(0); + isPaused.set(false); + isCancelled.set(false); + + // Start network monitoring + registerNetworkCallback(); + + // Begin upload process by initializing a session with the server + initializeUploadSession() + .thenAccept(sessionId -> { + if (sessionId != null && !sessionId.isEmpty()) { + this.uploadId = sessionId; + Log.d(TAG, "Upload session initialized with ID: " + uploadId); + + // Start uploading chunks + uploadNextChunks() + .thenAccept(isComplete -> { + if (isComplete) { + // All chunks uploaded, finalize the upload + finalizeUpload() + .thenAccept(resultFuture::complete) + .exceptionally(ex -> { + Log.e(TAG, "Error finalizing upload", ex); + resultFuture.complete(new UploadResult("Failed to finalize upload: " + ex.getMessage())); + return null; + }); + } else { + resultFuture.complete(new UploadResult("Upload incomplete or cancelled")); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error during chunk uploading", ex); + resultFuture.complete(new UploadResult("Chunk upload error: " + ex.getMessage())); + return null; + }); + } else { + Log.e(TAG, "Failed to initialize upload session"); + resultFuture.complete(new UploadResult("Failed to initialize upload session")); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error initializing upload session", ex); + resultFuture.complete(new UploadResult("Session initialization error: " + ex.getMessage())); + return null; + }); + + return resultFuture; + } + + /** + * Initialize an upload session with the server + * + * @return A future that completes with the session ID + */ + private CompletableFuture initializeUploadSession() { + CompletableFuture future = new CompletableFuture<>(); + + // Get server URL and client key from preferences + String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); + String clientKey = PreferencesUtil.getClientKey(context); + + // Create the session initialization URL + String initUrl = serverBaseUrl + "/upload/start"; + + // Build the request body with file metadata + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("filename", file.getName()) + .addFormDataPart("filesize", String.valueOf(totalBytes)) + .addFormDataPart("chunk_size", String.valueOf(chunkSize)) + .addFormDataPart("total_chunks", String.valueOf(totalChunks)) + .addFormDataPart("max_downloads", String.valueOf(maxDownloads)) + .addFormDataPart("expiry_hours", String.valueOf(expiryHours)) + .build(); + + // Build the request + Request request = new Request.Builder() + .url(initUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build(); + + Log.d(TAG, "Initializing upload session..."); + + // Execute the request + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Failed to initialize upload session", e); + future.complete(null); // Complete with null to indicate failure + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + if (response.isSuccessful()) { + try { + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + String sessionId = jsonResponse.getString("upload_id"); + future.complete(sessionId); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse session initialization response", e); + future.complete(null); + } + } else { + Log.e(TAG, "Session initialization failed with code: " + response.code()); + future.complete(null); + } + } + }); + + return future; + } + + /** + * Upload chunks to the server + * + * @return A future that completes when all chunks are uploaded + */ + // This runnable is defined as a class member to avoid initialization issues + private Runnable uploadProgressChecker; + private CompletableFuture uploadProgressFuture; + + /** + * Upload chunks to the server + * + * @return A future that completes when all chunks are uploaded + */ + private CompletableFuture uploadNextChunks() { + uploadProgressFuture = new CompletableFuture<>(); + + // Define the progress checker runnable + uploadProgressChecker = new Runnable() { + @Override + public void run() { + if (isCancelled.get()) { + uploadProgressFuture.complete(false); + return; + } + + if (isPaused.get()) { + // Upload is paused, do nothing + return; + } + + // If all chunks are complete, verify each index was uploaded + if (completedChunks.size() == totalChunks) { + // Additional verification: check if all required chunk indexes exist + boolean allChunksUploaded = true; + Set missingChunks = new HashSet<>(); + + for (int i = 0; i < totalChunks; i++) { + if (!completedChunks.contains(i)) { + missingChunks.add(i); + allChunksUploaded = false; + } + } + + if (allChunksUploaded) { + Log.d(TAG, "All chunks uploaded successfully"); + uploadProgressFuture.complete(true); + return; + } else { + Log.e(TAG, "Missing chunks despite having " + completedChunks.size() + + " chunks completed. Missing indexes: " + missingChunks); + // Continue with upload process to retry missing chunks + } + } + + // Find next chunks to upload + for (int i = 0; i < totalChunks && activeUploads.get() < maxParallelUploads; i++) { + final int chunkIndex = i; // Create final variable for use in lambda + if (!completedChunks.contains(chunkIndex) && !inProgressChunks.contains(chunkIndex)) { + // Start upload for this chunk + inProgressChunks.add(chunkIndex); + activeUploads.incrementAndGet(); + + uploadChunk(chunkIndex) + .thenAccept(success -> { + // Remove from in-progress + inProgressChunks.remove(chunkIndex); + activeUploads.decrementAndGet(); + + if (success) { + // Add to completed + completedChunks.add(chunkIndex); + + // Notify listener + if (progressListener != null) { + progressListener.onChunkComplete(chunkIndex, totalChunks); + } + + // Check progress again + uploadProgressChecker.run(); + } else if (!isCancelled.get()) { + // Retry failed chunks if not cancelled + if (!isPaused.get()) { + Log.d(TAG, "Scheduling retry for chunk " + chunkIndex); + // Reschedule after a delay + // Using a simple retry here, but could implement exponential backoff + java.util.concurrent.ScheduledExecutorService scheduler = + java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + scheduler.schedule(uploadProgressChecker, 1000, TimeUnit.MILLISECONDS); + scheduler.shutdown(); + } + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error uploading chunk " + chunkIndex, ex); + inProgressChunks.remove(chunkIndex); + activeUploads.decrementAndGet(); + return null; + }); + } + } + + // If no active uploads and no more chunks to upload, but not all chunks are complete, + // something went wrong + if (activeUploads.get() == 0 && inProgressChunks.isEmpty() && + completedChunks.size() < totalChunks && !isPaused.get()) { + Log.e(TAG, "Upload stalled with " + completedChunks.size() + "/" + totalChunks + " chunks completed"); + uploadProgressFuture.complete(false); + } + } + }; + + // Start uploading chunks + uploadProgressChecker.run(); + + return uploadProgressFuture; + } + + /** + * Upload a single chunk to the server + * + * @param chunkIndex The index of the chunk to upload + * @return A future that completes when the chunk is uploaded + */ + private CompletableFuture uploadChunk(int chunkIndex) { + CompletableFuture future = new CompletableFuture<>(); + + // Get server URL and client key from preferences + String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); + String clientKey = PreferencesUtil.getClientKey(context); + + // Create the chunk upload URL + String chunkUrl = serverBaseUrl + "/upload/chunk"; + + try { + // Calculate chunk start and end positions + long start = (long) chunkIndex * chunkSize; + long end = Math.min(start + chunkSize, file.length()); + int currentChunkSize = (int) (end - start); + + Log.d(TAG, "Uploading chunk " + chunkIndex + " (" + currentChunkSize + " bytes)"); + + // Read the chunk data + byte[] buffer = new byte[currentChunkSize]; + try (FileInputStream inputStream = new FileInputStream(file)) { + inputStream.skip(start); + int bytesRead = inputStream.read(buffer); + if (bytesRead != currentChunkSize) { + Log.e(TAG, "Failed to read chunk data: expected " + currentChunkSize + + " bytes, got " + bytesRead); + future.complete(false); + return future; + } + } + + // Create the request body + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .addFormDataPart("chunk_index", String.valueOf(chunkIndex)) + .addFormDataPart("total_chunks", String.valueOf(totalChunks)) + .addFormDataPart("chunk", file.getName() + ".part" + chunkIndex, + RequestBody.create(MediaType.parse("application/octet-stream"), buffer)) + .build(); + + // Build the request + Request request = new Request.Builder() + .url(chunkUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build(); + + // Execute the request + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Failed to upload chunk " + chunkIndex, e); + future.complete(false); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + if (response.isSuccessful()) { + try { + // Parse the response to make sure the server confirmed success + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + + boolean success = jsonResponse.optBoolean("success", false); + int confirmedChunkIndex = jsonResponse.optInt("chunk_index", -1); + + if (success && (confirmedChunkIndex == chunkIndex || confirmedChunkIndex == -1)) { + // Update progress + long newTotal = uploadedBytes.addAndGet(currentChunkSize); + int percentComplete = (int) (newTotal * 100 / totalBytes); + + // Notify listener + if (progressListener != null) { + progressListener.onProgress(newTotal, totalBytes, percentComplete); + } + + Log.d(TAG, "Chunk " + chunkIndex + " uploaded successfully. Progress: " + + percentComplete + "%"); + future.complete(true); + } else { + Log.e(TAG, "Server reported issue with chunk " + chunkIndex + ": " + responseBody); + future.complete(false); + } + } catch (Exception e) { + Log.e(TAG, "Error parsing chunk upload response for chunk " + chunkIndex, e); + future.complete(false); + } + } else { + try { + String errorBody = response.body().string(); + Log.e(TAG, "Chunk " + chunkIndex + " upload failed with code: " + response.code() + + ", Body: " + errorBody); + } catch (Exception e) { + Log.e(TAG, "Chunk " + chunkIndex + " upload failed with code: " + response.code()); + } + future.complete(false); + } + } + }); + + } catch (IOException e) { + Log.e(TAG, "Error preparing chunk " + chunkIndex, e); + future.complete(false); + } + + return future; + } + + /** + * Verify which chunks the server has actually received + * + * @return A CompletableFuture with a Set of missing chunk indexes + */ + private CompletableFuture> verifyChunksWithServer() { + CompletableFuture> future = new CompletableFuture<>(); + Set missingChunks = new HashSet<>(); + + // Get server URL and client key from preferences + String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); + String clientKey = PreferencesUtil.getClientKey(context); + + // Create the verification URL + String verifyUrl = serverBaseUrl + "/upload/verify"; + + // Build the request body + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .build(); + + // Build the request + Request request = new Request.Builder() + .url(verifyUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build(); + + Log.d(TAG, "Verifying uploaded chunks with server..."); + + // Execute the request + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Failed to verify chunks with server", e); + // If we can't verify, assume all are ok since we've tracked them locally + future.complete(new HashSet<>()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + if (response.isSuccessful()) { + try { + String responseBody = response.body().string(); + JSONObject jsonResponse = new JSONObject(responseBody); + + // Check for missing chunks + if (jsonResponse.has("missing_chunks")) { + JSONArray missingArray = jsonResponse.getJSONArray("missing_chunks"); + for (int i = 0; i < missingArray.length(); i++) { + missingChunks.add(missingArray.getInt(i)); + } + Log.d(TAG, "Server missing chunks: " + missingChunks); + } + + future.complete(missingChunks); + } catch (Exception e) { + Log.e(TAG, "Error parsing chunk verification response", e); + future.complete(new HashSet<>()); + } + } else { + try { + String errorBody = response.body().string(); + Log.e(TAG, "Chunk verification failed with code: " + response.code() + ", Body: " + errorBody); + } catch (Exception e) { + Log.e(TAG, "Chunk verification failed with code: " + response.code()); + } + + // If verification fails, try the finalization anyway with our client-side tracking + future.complete(new HashSet<>()); + } + } + }); + + return future; + } + + /** + * Retry uploading specific chunks that the server is missing + * + * @param chunkIndexes Set of chunk indexes to retry + * @return CompletableFuture indicating if all retries were successful + */ + private CompletableFuture retrySpecificChunks(Set chunkIndexes) { + CompletableFuture future = new CompletableFuture<>(); + + if (chunkIndexes.isEmpty()) { + future.complete(true); + return future; + } + + Log.d(TAG, "Retrying specific chunks: " + chunkIndexes); + + // Create a counter for completed retries + AtomicInteger completedRetries = new AtomicInteger(0); + AtomicInteger successfulRetries = new AtomicInteger(0); + int totalRetries = chunkIndexes.size(); + + // Try each chunk + for (Integer chunkIndex : chunkIndexes) { + Log.d(TAG, "Retrying upload of chunk " + chunkIndex); + + uploadChunk(chunkIndex) + .thenAccept(success -> { + int completed = completedRetries.incrementAndGet(); + + if (success) { + successfulRetries.incrementAndGet(); + Log.d(TAG, "Successfully reuploaded chunk " + chunkIndex); + } else { + Log.e(TAG, "Failed to reupload chunk " + chunkIndex); + } + + // If all retries have completed, complete the future + if (completed == totalRetries) { + boolean allSuccessful = successfulRetries.get() == totalRetries; + Log.d(TAG, "All chunk retries completed. " + successfulRetries.get() + "/" + + totalRetries + " successful"); + future.complete(allSuccessful); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Exception retrying chunk " + chunkIndex, ex); + int completed = completedRetries.incrementAndGet(); + + // If all retries have completed, complete the future + if (completed == totalRetries) { + boolean allSuccessful = successfulRetries.get() == totalRetries; + Log.d(TAG, "All chunk retries completed. " + successfulRetries.get() + "/" + + totalRetries + " successful"); + future.complete(allSuccessful); + } + return null; + }); + } + + return future; + } + + /** + * Finalize the upload on the server + * + * @return A future that completes with the upload result + */ + private CompletableFuture finalizeUpload() { + CompletableFuture future = new CompletableFuture<>(); + + // Verify all chunks are uploaded before finalizing + boolean allChunksUploaded = true; + Set missingChunks = new HashSet<>(); + + for (int i = 0; i < totalChunks; i++) { + if (!completedChunks.contains(i)) { + missingChunks.add(i); + allChunksUploaded = false; + } + } + + if (!allChunksUploaded) { + Log.e(TAG, "Cannot finalize: missing chunks " + missingChunks); + future.complete(new UploadResult("Cannot finalize: missing chunks " + missingChunks)); + return future; + } + + // Get server URL and client key from preferences + String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); + String clientKey = PreferencesUtil.getClientKey(context); + + // Before finalizing, verify with the server which chunks it has received + verifyChunksWithServer() + .thenAccept(serverMissingChunks -> { + if (!serverMissingChunks.isEmpty()) { + Log.e(TAG, "Server missing chunks: " + serverMissingChunks + ", reuploading missing chunks"); + + // Reset these chunks as not uploaded so they'll be retried + for (Integer chunkIndex : serverMissingChunks) { + completedChunks.remove(chunkIndex); + } + + // Retry uploading the missing chunks + retrySpecificChunks(serverMissingChunks) + .thenAccept(retrySuccess -> { + if (retrySuccess) { + // Now try finalizing again + Log.d(TAG, "Missing chunks reuploaded, finalizing again"); + performFinalizeRequest(future); + } else { + Log.e(TAG, "Failed to reupload missing chunks"); + future.complete(new UploadResult("Failed to reupload missing chunks")); + } + }); + } else { + // All chunks are verified by the server, proceed with finalization + performFinalizeRequest(future); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error verifying chunks with server", ex); + // Try to finalize anyway + performFinalizeRequest(future); + return null; + }); + + return future; + } + + /** + * Perform the finalize request to the server + * + * @param future The CompletableFuture to complete with the upload result + */ + private void performFinalizeRequest(CompletableFuture future) { + // Get server URL and client key from preferences + String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context); + String clientKey = PreferencesUtil.getClientKey(context); + + // Create the finalize URL + String finalizeUrl = serverBaseUrl + "/upload/complete"; + + // Build the request body + RequestBody requestBody = new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("upload_id", uploadId) + .addFormDataPart("filename", file.getName()) + .addFormDataPart("total_chunks", String.valueOf(totalChunks)) + .addFormDataPart("max_downloads", String.valueOf(maxDownloads)) + .addFormDataPart("expiry_hours", String.valueOf(expiryHours)) + .build(); + + // Build the request + Request request = new Request.Builder() + .url(finalizeUrl) + .header(NetworkConstants.CLIENT_KEY_HEADER, clientKey) + .post(requestBody) + .build(); + + Log.d(TAG, "Finalizing upload..."); + + // Execute the request + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + Log.e(TAG, "Failed to finalize upload", e); + future.complete(new UploadResult("Failed to finalize upload: " + 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 finalized successfully! Code: " + uploadCode); + + // Create the result + UploadResult result = new UploadResult( + uploadCode, file.getName(), maxDownloads, expiryHours); + + // Notify listener + if (progressListener != null) { + progressListener.onUploadComplete(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 { + try { + String errorBody = response.body().string(); + Log.e(TAG, "Finalize failed with code: " + response.code() + ", Body: " + errorBody); + + // Try to parse error body to see if there are missing chunks we can retry + try { + JSONObject errorJson = new JSONObject(errorBody); + if (errorJson.has("missing_chunks")) { + JSONArray missingArray = errorJson.getJSONArray("missing_chunks"); + Set missingChunks = new HashSet<>(); + + for (int i = 0; i < missingArray.length(); i++) { + missingChunks.add(missingArray.getInt(i)); + } + + // Automatically retry these specific chunks + if (!missingChunks.isEmpty()) { + Log.d(TAG, "Server reported missing chunks: " + missingChunks + + ". Will retry these chunks specifically."); + + // Remove these chunks from completedChunks + for (Integer chunkIndex : missingChunks) { + completedChunks.remove(chunkIndex); + } + + retrySpecificChunks(missingChunks) + .thenAccept(retrySuccess -> { + if (retrySuccess) { + // Try finalizing again + Log.d(TAG, "Successfully reuploaded missing chunks, retrying finalize"); + performFinalizeRequest(future); + } else { + Log.e(TAG, "Failed to reupload missing chunks, giving up"); + future.complete(new UploadResult("Failed to reupload missing chunks")); + } + }); + + // Return early as we'll complete the future after retrying + return; + } + } + } catch (JSONException e) { + // Not a JSON response or doesn't have missing_chunks key + } + + future.complete(new UploadResult("Failed to finalize upload: " + response.code())); + } catch (Exception e) { + Log.e(TAG, "Error reading finalize error response", e); + future.complete(new UploadResult("Failed to finalize upload: " + response.code())); + } + } + } + }); + } + + /** + * Pause the upload + */ + public void pauseUpload() { + if (!isPaused.getAndSet(true)) { + Log.d(TAG, "Upload paused"); + + // Notify listener + if (progressListener != null) { + progressListener.onPaused(); + } + } + } + + /** + * Resume the upload + */ + public void resumeUpload() { + if (isPaused.getAndSet(false)) { + Log.d(TAG, "Upload resumed"); + + // Notify listener + if (progressListener != null) { + progressListener.onResumed(); + } + + // Start uploading again + uploadNextChunks() + .thenAccept(isComplete -> { + if (isComplete) { + // All chunks uploaded, finalize the upload + finalizeUpload() + .thenAccept(result -> { + // Notify listener + if (progressListener != null) { + progressListener.onUploadComplete(result); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error finalizing upload", ex); + if (progressListener != null) { + progressListener.onError("Failed to finalize upload: " + ex.getMessage()); + } + return null; + }); + } else if (progressListener != null) { + progressListener.onError("Upload incomplete or cancelled"); + } + }) + .exceptionally(ex -> { + Log.e(TAG, "Error during chunk uploading", ex); + if (progressListener != null) { + progressListener.onError("Chunk upload error: " + ex.getMessage()); + } + return null; + }); + } + } + + /** + * Cancel the upload + */ + public void cancelUpload() { + isCancelled.set(true); + Log.d(TAG, "Upload cancelled"); + + // Unregister network callback + unregisterNetworkCallback(); + } + + /** + * Set up HTTP client with timeouts based on connection type + */ + private void setupHttpClient() { + int connectionTimeout = isWifiConnection ? + NetworkConstants.CONNECTION_TIMEOUT : NetworkConstants.CONNECTION_TIMEOUT / 2; + int writeTimeout = isWifiConnection ? + NetworkConstants.WRITE_TIMEOUT : NetworkConstants.WRITE_TIMEOUT / 2; + int readTimeout = isWifiConnection ? + NetworkConstants.READ_TIMEOUT : NetworkConstants.READ_TIMEOUT / 2; + + client = new OkHttpClient.Builder() + .connectTimeout(connectionTimeout, TimeUnit.SECONDS) + .writeTimeout(writeTimeout, TimeUnit.SECONDS) + .readTimeout(readTimeout, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build(); + } + + /** + * Detect current connection type (WiFi or Mobile) + */ + private void detectConnectionType() { + NetworkInfo activeNetwork = connectivityManager.getActiveNetworkInfo(); + isWifiConnection = activeNetwork != null && + activeNetwork.getType() == ConnectivityManager.TYPE_WIFI; + + Log.d(TAG, "Connection type detected: " + (isWifiConnection ? "WiFi" : "Mobile")); + } + + /** + * Register network callback to monitor connection changes + */ + private void registerNetworkCallback() { + // Create network callback if not already created + if (networkCallback == null) { + networkCallback = new NetworkCallback(); + } + + // Register the callback + NetworkRequest.Builder builder = new NetworkRequest.Builder(); + connectivityManager.registerNetworkCallback(builder.build(), networkCallback); + + Log.d(TAG, "Network callback registered"); + } + + /** + * Unregister network callback + */ + private void unregisterNetworkCallback() { + if (networkCallback != null) { + try { + connectivityManager.unregisterNetworkCallback(networkCallback); + Log.d(TAG, "Network callback unregistered"); + } catch (IllegalArgumentException e) { + // Callback was not registered or already unregistered + Log.w(TAG, "Failed to unregister network callback", e); + } + } + } + + /** + * Network callback for handling connection changes + */ + private class NetworkCallback extends ConnectivityManager.NetworkCallback { + @Override + public void onAvailable(Network network) { + super.onAvailable(network); + + // Check if connection type changed + boolean wasWifi = isWifiConnection; + detectConnectionType(); + + Log.d(TAG, "Network available: " + (isWifiConnection ? "WiFi" : "Mobile")); + + // If we were paused due to network loss, resume + if (isPaused.get()) { + Log.d(TAG, "Network restored, resuming upload"); + resumeUpload(); + } + + // If connection type changed, adjust settings + if (wasWifi != isWifiConnection) { + Log.d(TAG, "Connection type changed, adjusting settings"); + setupHttpClient(); + } + } + + @Override + public void onLost(Network network) { + super.onLost(network); + + Log.d(TAG, "Network lost"); + + // Pause upload to prevent failures + pauseUpload(); + } + + @Override + public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) { + super.onCapabilitiesChanged(network, capabilities); + + boolean newIsWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI); + + // If connection type changed, adjust settings + if (newIsWifi != isWifiConnection) { + isWifiConnection = newIsWifi; + Log.d(TAG, "Connection capabilities changed to: " + (isWifiConnection ? "WiFi" : "Mobile")); + setupHttpClient(); + } + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java new file mode 100644 index 0000000..5f04fcb --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/upload/UploadStrategySelector.java @@ -0,0 +1,95 @@ +package com.mattintech.simplelogupload.upload; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; + +import com.mattintech.simplelogupload.constant.AppConstants; + +import java.io.File; + +/** + * Helper class to determine the best upload strategy based on file size and network conditions + */ +public class UploadStrategySelector { + private static final String TAG = AppConstants.APP_TAG + "UploadStrategy"; + + // Threshold for using chunked uploads + private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi + private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile + + /** + * Check if chunked uploads should be used for the given file + * + * @param context Application context + * @param file The file to upload + * @return True if chunked upload should be used, false otherwise + */ + public static boolean shouldUseChunkedUpload(Context context, File file) { + if (file == null) { + return false; + } + + long fileSize = file.length(); + boolean isWifiConnection = isWifiConnection(context); + long threshold = isWifiConnection ? CHUNKED_UPLOAD_THRESHOLD_WIFI : CHUNKED_UPLOAD_THRESHOLD_MOBILE; + + boolean useChunked = fileSize > threshold; + Log.d(TAG, "File size: " + fileSize + " bytes, Connection: " + (isWifiConnection ? "WiFi" : "Mobile") + + ", Using chunked upload: " + useChunked); + + return useChunked; + } + + /** + * Check if the device is currently connected to WiFi + * + * @param context Application context + * @return True if connected to WiFi, false otherwise + */ + public static boolean isWifiConnection(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && activeNetwork.getType() == ConnectivityManager.TYPE_WIFI; + } + + /** + * Get the network type name for logging + * + * @param context Application context + * @return String representing the network type ("WiFi", "Mobile", or "Unknown") + */ + public static String getNetworkTypeName(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + + if (info == null || !info.isConnected()) { + return "Unknown"; + } + + if (info.getType() == ConnectivityManager.TYPE_WIFI) { + return "WiFi"; + } + + if (info.getType() == ConnectivityManager.TYPE_MOBILE) { + String subType = ""; + switch (info.getSubtype()) { + case 13: // LTE + subType = "LTE"; + break; + case 19: // 5G + subType = "5G"; + break; + case 20: // 5G+ + subType = "5G+"; + break; + default: + subType = info.getSubtypeName(); + } + return "Mobile (" + subType + ")"; + } + + return "Unknown"; + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java index bae034a..79dc628 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java +++ b/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java @@ -24,7 +24,8 @@ public class PermissionUtil { public static final int REQUEST_MANAGE_EXTERNAL_STORAGE = 101; public static final int REQUEST_NOTIFICATION_PERMISSION = 102; - + public static final int REQUEST_CAMERA_PERMISSION = 103; + /** * Checks if the app has notification permission * @@ -93,4 +94,31 @@ public class PermissionUtil { } } } + + /** + * Checks if the app has camera permission + * + * @param context The context + * @return true if the app has camera permission, false otherwise + */ + public static boolean hasCameraPermission(Context context) { + return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + /** + * Requests camera permission + * + * @param activity The activity + */ + public static void requestCameraPermission(Activity activity) { + if (!hasCameraPermission(activity)) { + Log.d(TAG, "Requesting camera permission"); + ActivityCompat.requestPermissions( + activity, + new String[]{Manifest.permission.CAMERA}, + REQUEST_CAMERA_PERMISSION + ); + } + } } diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/QrScannerUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/QrScannerUtil.java new file mode 100644 index 0000000..8045bce --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/util/QrScannerUtil.java @@ -0,0 +1,54 @@ +package com.mattintech.simplelogupload.util; + +import com.journeyapps.barcodescanner.ScanOptions; +import com.mattintech.simplelogupload.model.QrServerConfig; + +/** + * Utility class for QR code scanning + */ +public class QrScannerUtil { + + /** + * Callback interface for QR scan results + */ + public interface QrScanCallback { + void onQrScanned(QrServerConfig config); + void onQrScanCancelled(); + void onQrScanError(String errorMessage); + } + + /** + * Create scan options for QR code scanning + * + * @return ScanOptions configured for QR codes + */ + public static ScanOptions createScanOptions() { + ScanOptions options = new ScanOptions(); + options.setDesiredBarcodeFormats(ScanOptions.QR_CODE); + options.setPrompt("Scan server configuration QR code"); + options.setBeepEnabled(true); + options.setBarcodeImageEnabled(false); + options.setOrientationLocked(true); + return options; + } + + /** + * Process scan result + * + * @param contents The scanned QR code contents + * @param callback The callback to handle the result + */ + public static void processScanResult(String contents, QrScanCallback callback) { + if (contents == null) { + callback.onQrScanCancelled(); + return; + } + + try { + QrServerConfig config = QrServerConfig.fromQrData(contents); + callback.onQrScanned(config); + } catch (QrServerConfig.QrConfigException e) { + callback.onQrScanError(e.getMessage()); + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/ServerSetupActivity.java b/app/src/main/java/com/mattintech/simplelogupload/view/ServerSetupActivity.java index 77b6651..89844e8 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/view/ServerSetupActivity.java +++ b/app/src/main/java/com/mattintech/simplelogupload/view/ServerSetupActivity.java @@ -1,28 +1,38 @@ package com.mattintech.simplelogupload.view; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.widget.Button; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AppCompatActivity; import com.google.android.material.textfield.TextInputEditText; +import com.journeyapps.barcodescanner.ScanContract; +import com.journeyapps.barcodescanner.ScanOptions; +import com.journeyapps.barcodescanner.ScanIntentResult; import com.mattintech.simplelogupload.R; import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.model.QrServerConfig; +import com.mattintech.simplelogupload.util.PermissionUtil; import com.mattintech.simplelogupload.util.PreferencesUtil; +import com.mattintech.simplelogupload.util.QrScannerUtil; /** * Activity for server setup */ public class ServerSetupActivity extends AppCompatActivity { private static final String TAG = AppConstants.APP_TAG + "ServerSetupActivity"; - + private TextInputEditText serverAddressInput; private TextInputEditText serverPortInput; private TextInputEditText clientKeyInput; + private ActivityResultLauncher qrScannerLauncher; + private Button scanQrButton; @Override protected void onCreate(Bundle savedInstanceState) { @@ -34,7 +44,17 @@ public class ServerSetupActivity extends AppCompatActivity { serverPortInput = findViewById(R.id.serverPortInput); clientKeyInput = findViewById(R.id.clientKeyInput); Button finishSetupButton = findViewById(R.id.finishSetupButton); - + scanQrButton = findViewById(R.id.scanQrButton); + + // Initialize QR scanner launcher + qrScannerLauncher = registerForActivityResult( + new ScanContract(), + result -> handleQrScanResult(result) + ); + + // Set up QR scan button click listener + scanQrButton.setOnClickListener(v -> initiateQrScan()); + // Set up action bar if (getSupportActionBar() != null) { getSupportActionBar().setTitle("Server Setup"); @@ -103,7 +123,81 @@ public class ServerSetupActivity extends AppCompatActivity { Toast.makeText(this, "Error saving settings: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } - + + /** + * Initiate QR code scan + */ + private void initiateQrScan() { + // Check camera permission + if (!PermissionUtil.hasCameraPermission(this)) { + PermissionUtil.requestCameraPermission(this); + return; + } + + // Launch scanner + ScanOptions options = QrScannerUtil.createScanOptions(); + qrScannerLauncher.launch(options); + } + + /** + * Handle QR scan result + * + * @param result The scan result + */ + private void handleQrScanResult(ScanIntentResult result) { + QrScannerUtil.processScanResult( + result.getContents(), + new QrScannerUtil.QrScanCallback() { + @Override + public void onQrScanned(QrServerConfig config) { + // Auto-fill the input fields + serverAddressInput.setText(config.getServer()); + serverPortInput.setText(String.valueOf(config.getPort())); + clientKeyInput.setText(config.getKey()); + + Toast.makeText(ServerSetupActivity.this, + R.string.qr_scan_success, + Toast.LENGTH_SHORT).show(); + + Log.d(TAG, "QR code scanned successfully"); + } + + @Override + public void onQrScanCancelled() { + Toast.makeText(ServerSetupActivity.this, + R.string.qr_scan_cancelled, + Toast.LENGTH_SHORT).show(); + } + + @Override + public void onQrScanError(String errorMessage) { + Toast.makeText(ServerSetupActivity.this, + getString(R.string.qr_scan_error, errorMessage), + Toast.LENGTH_LONG).show(); + Log.e(TAG, "QR scan error: " + errorMessage); + } + } + ); + } + + /** + * Handle permission request results + */ + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PermissionUtil.REQUEST_CAMERA_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted, initiate scan + initiateQrScan(); + } else { + // Permission denied + Toast.makeText(this, R.string.camera_permission_denied, Toast.LENGTH_LONG).show(); + } + } + } + @Override public boolean onSupportNavigateUp() { onBackPressed(); diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/SettingsActivity.java b/app/src/main/java/com/mattintech/simplelogupload/view/SettingsActivity.java index 8110644..e9178de 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/view/SettingsActivity.java +++ b/app/src/main/java/com/mattintech/simplelogupload/view/SettingsActivity.java @@ -1,28 +1,38 @@ package com.mattintech.simplelogupload.view; +import android.content.pm.PackageManager; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.widget.Button; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AppCompatActivity; import com.google.android.material.textfield.TextInputEditText; +import com.journeyapps.barcodescanner.ScanContract; +import com.journeyapps.barcodescanner.ScanOptions; +import com.journeyapps.barcodescanner.ScanIntentResult; import com.mattintech.simplelogupload.R; import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.model.QrServerConfig; +import com.mattintech.simplelogupload.util.PermissionUtil; import com.mattintech.simplelogupload.util.PreferencesUtil; +import com.mattintech.simplelogupload.util.QrScannerUtil; /** * Activity for application settings */ public class SettingsActivity extends AppCompatActivity { private static final String TAG = AppConstants.APP_TAG + "SettingsActivity"; - + private TextInputEditText serverAddressInput; private TextInputEditText serverPortInput; private TextInputEditText clientKeyInput; - + private ActivityResultLauncher qrScannerLauncher; + private Button scanQrButton; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -34,7 +44,17 @@ public class SettingsActivity extends AppCompatActivity { clientKeyInput = findViewById(R.id.clientKeyInput); Button saveButton = findViewById(R.id.saveSettingsButton); Button resetButton = findViewById(R.id.resetSettingsButton); - + scanQrButton = findViewById(R.id.scanQrButton); + + // Initialize QR scanner launcher + qrScannerLauncher = registerForActivityResult( + new ScanContract(), + result -> handleQrScanResult(result) + ); + + // Set up QR scan button click listener + scanQrButton.setOnClickListener(v -> initiateQrScan()); + // Set up action bar if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -127,7 +147,81 @@ public class SettingsActivity extends AppCompatActivity { loadSettings(); Toast.makeText(this, "Settings reset to defaults", Toast.LENGTH_SHORT).show(); } - + + /** + * Initiate QR code scan + */ + private void initiateQrScan() { + // Check camera permission + if (!PermissionUtil.hasCameraPermission(this)) { + PermissionUtil.requestCameraPermission(this); + return; + } + + // Launch scanner + ScanOptions options = QrScannerUtil.createScanOptions(); + qrScannerLauncher.launch(options); + } + + /** + * Handle QR scan result + * + * @param result The scan result + */ + private void handleQrScanResult(ScanIntentResult result) { + QrScannerUtil.processScanResult( + result.getContents(), + new QrScannerUtil.QrScanCallback() { + @Override + public void onQrScanned(QrServerConfig config) { + // Auto-fill the input fields + serverAddressInput.setText(config.getServer()); + serverPortInput.setText(String.valueOf(config.getPort())); + clientKeyInput.setText(config.getKey()); + + Toast.makeText(SettingsActivity.this, + R.string.qr_scan_success, + Toast.LENGTH_SHORT).show(); + + Log.d(TAG, "QR code scanned successfully"); + } + + @Override + public void onQrScanCancelled() { + Toast.makeText(SettingsActivity.this, + R.string.qr_scan_cancelled, + Toast.LENGTH_SHORT).show(); + } + + @Override + public void onQrScanError(String errorMessage) { + Toast.makeText(SettingsActivity.this, + getString(R.string.qr_scan_error, errorMessage), + Toast.LENGTH_LONG).show(); + Log.e(TAG, "QR scan error: " + errorMessage); + } + } + ); + } + + /** + * Handle permission request results + */ + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PermissionUtil.REQUEST_CAMERA_PERMISSION) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted, initiate scan + initiateQrScan(); + } else { + // Permission denied + Toast.makeText(this, R.string.camera_permission_denied, Toast.LENGTH_LONG).show(); + } + } + } + @Override public boolean onSupportNavigateUp() { onBackPressed(); diff --git a/app/src/main/res/drawable/ic_qr_scan.xml b/app/src/main/res/drawable/ic_qr_scan.xml new file mode 100644 index 0000000..0fd35ad --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_scan.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_server_setup.xml b/app/src/main/res/layout/activity_server_setup.xml index 01ee407..413d12f 100644 --- a/app/src/main/res/layout/activity_server_setup.xml +++ b/app/src/main/res/layout/activity_server_setup.xml @@ -88,6 +88,27 @@ + +