diff --git a/app/build.gradle b/app/build.gradle index 4235f43..43f6656 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,17 @@ - plugins { id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' } android { namespace 'com.mattintech.simplelogupload' - compileSdkVersion 34 + compileSdkVersion 36 + defaultConfig { applicationId "com.mattintech.simplelogupload" minSdkVersion 30 - targetSdkVersion 33 + targetSdkVersion 36 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -21,14 +23,69 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + // Optional: explicitly specify Java toolchain + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } } dependencies { + // Kotlin + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Core Android UI implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.8.0' + implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + + // Networking implementation 'com.squareup.okhttp3:okhttp:4.9.0' + implementation 'com.android.support:support-annotations:28.0.0' + implementation 'androidx.palette:palette:1.0.0' + + // Room + def room_version = "2.6.1" + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + // Lifecycle + def lifecycle_version = "2.6.2" + implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + + // UI Components + implementation "androidx.recyclerview:recyclerview:1.3.2" + implementation "androidx.cardview:cardview:1.0.0" + + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + + // Testing testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + // Kotlin compatibility + constraints { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") { + because("Merged into kotlin-stdlib") + } + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") { + because("Merged into kotlin-stdlib") + } + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 57dec3b..a05985e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + + + + + + + + + + + + + + + + + + + + - + - - + android:name=".service.FileUploadForegroundService" + android:enabled="true" + android:exported="false" + android:foregroundServiceType="dataSync" /> \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/simplelogupload/Constant.java b/app/src/main/java/com/mattintech/simplelogupload/Constant.java deleted file mode 100644 index c4ca731..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/Constant.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.mattintech.simplelogupload; - -public class Constant { - public static final String APP_TAG = "SLU::"; - - // Server constants - public static final String SERVER_URL = "http://172.16.100.167:7777/upload"; // HTTP protocol explicitly specified - - // File size limits - public static final long MAX_FILE_SIZE = 2L * 1024 * 1024 * 1024; // 2GB - - // Upload parameters - public static final String PARAM_FILE = "file"; - public static final String PARAM_MAX_DOWNLOADS = "max_downloads"; - public static final String PARAM_EXPIRY_HOURS = "expiry_hours"; - - // Default values - public static final int DEFAULT_MAX_DOWNLOADS = 1; - public static final int DEFAULT_EXPIRY_HOURS = 24; - public static final int MAX_EXPIRY_HOURS = 24; - - // Broadcast actions - public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE"; - public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; - - // Intent extras - public static final String EXTRA_UPLOAD_CODE = "upload_code"; - public static final String EXTRA_UPLOAD_ERROR = "upload_error"; -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/FileUploadForegroundService.java b/app/src/main/java/com/mattintech/simplelogupload/FileUploadForegroundService.java deleted file mode 100644 index cb640b1..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/FileUploadForegroundService.java +++ /dev/null @@ -1,198 +0,0 @@ -package com.mattintech.simplelogupload; - -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.Environment; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.Comparator; -import java.util.concurrent.TimeUnit; - -import org.json.JSONException; -import org.json.JSONObject; - -import com.mattintech.simplelogupload.model.UploadResult; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - -public class FileUploadForegroundService extends Service { - - private static final String TAG = Constant.APP_TAG + "FileUploadService"; - private static final String CHANNEL_ID = "FileUploadChannel"; - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.d(TAG, "Foreground Service started."); - - // Create a notification and run the service in the foreground - createNotificationChannel(); - Notification notification = createNotification(); - startForeground(1, notification); - - Log.d(TAG, "Notification created."); - - // Start file upload process - File dir = new File(Environment.getExternalStorageDirectory(), "log"); - File mostRecentFile = findMostRecentFile(dir); - if (mostRecentFile != null) { - uploadFile(mostRecentFile); - } else { - Log.e(TAG, "No DUMPSTATE file found."); - sendErrorBroadcast("No DUMPSTATE file found"); - stopSelf(); - } - - return START_NOT_STICKY; - } - - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - private Notification createNotification() { - Intent notificationIntent = new Intent(this, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE); - - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("File Upload") - .setContentText("Uploading file in progress...") - .setSmallIcon(R.drawable.logupload) - .setContentIntent(pendingIntent) - .build(); - } - - 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_HIGH; // Change to HIGH to ensure visibility - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); - channel.setDescription(description); - - NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.createNotificationChannel(channel); - } - } - - private File findMostRecentFile(File dir) { - File[] files = dir.listFiles((d, name) -> name.matches("dumpState_.*\\.zip")); - return (files != null && files.length > 0) ? Arrays.stream(files) - .max(Comparator.comparingLong(File::lastModified)) - .orElse(null) : null; - } - - private void uploadFile(File file) { - // Validate file size before upload - if (file.length() > Constant.MAX_FILE_SIZE) { - Log.e(TAG, "File too large: " + file.length() + " bytes (max: " + Constant.MAX_FILE_SIZE + " bytes)"); - sendErrorBroadcast("File size exceeds the maximum allowed size of 2GB"); - stopSelf(); - return; - } - - // Create OkHttpClient with timeout configuration for large files - OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(180, TimeUnit.SECONDS) // 3 minutes timeout for large uploads - .readTimeout(60, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length()); - - RequestBody fileBody = RequestBody.create(file, MediaType.parse("application/zip")); - MultipartBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart(Constant.PARAM_FILE, file.getName(), fileBody) - .addFormDataPart(Constant.PARAM_MAX_DOWNLOADS, String.valueOf(Constant.DEFAULT_MAX_DOWNLOADS)) - .addFormDataPart(Constant.PARAM_EXPIRY_HOURS, String.valueOf(Constant.DEFAULT_EXPIRY_HOURS)) - .build(); - - Request request = new Request.Builder() - .url(Constant.SERVER_URL) - .post(requestBody) - .build(); - - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - e.printStackTrace(); - String errorMsg = "Upload failed: " + e.getMessage(); - Log.e(TAG, errorMsg); - sendErrorBroadcast(errorMsg); - stopSelf(); - } - - @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); - sendSuccessBroadcast(uploadCode); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse server response", e); - sendErrorBroadcast("Failed to parse server response"); - } - } else { - String errorBody = response.body().string(); - String 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 - } - - sendErrorBroadcast(errorMsg); - } - stopSelf(); - } - }); - } - - private void sendSuccessBroadcast(String uploadCode) { - Intent intent = new Intent(Constant.ACTION_UPLOAD_COMPLETE); - intent.putExtra(Constant.EXTRA_UPLOAD_CODE, uploadCode); - sendBroadcast(intent); - } - - private void sendErrorBroadcast(String errorMessage) { - Intent intent = new Intent(Constant.ACTION_UPLOAD_ERROR); - intent.putExtra(Constant.EXTRA_UPLOAD_ERROR, errorMessage); - sendBroadcast(intent); - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/FileUploadJobService.java b/app/src/main/java/com/mattintech/simplelogupload/FileUploadJobService.java deleted file mode 100644 index ba74603..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/FileUploadJobService.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.mattintech.simplelogupload; - -import android.app.job.JobParameters; -import android.app.job.JobService; -import android.content.Intent; -import android.os.Environment; -import android.util.Log; -import android.widget.Toast; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.Comparator; -import java.util.concurrent.TimeUnit; - -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; - -public class FileUploadJobService extends JobService { - private static final String TAG = Constant.APP_TAG + "FileUploadJobService:"; - - @Override - public boolean onStartJob(JobParameters params) { - File dir = new File(Environment.getExternalStorageDirectory(), "log"); - File mostRecentFile = findMostRecentFile(dir); - - if (mostRecentFile != null) { - Toast.makeText(this, "Starting Upload", Toast.LENGTH_SHORT).show(); - uploadFile(mostRecentFile, params); - } else { - Toast.makeText(getApplicationContext(), "No DUMPSTATE file found.", Toast.LENGTH_SHORT).show(); - sendErrorBroadcast("No DUMPSTATE file found"); - jobFinished(params, false); - } - - return true; - } - - @Override - public boolean onStopJob(JobParameters params) { - return false; - } - - private File findMostRecentFile(File dir) { - File[] files = dir.listFiles((d, name) -> name.matches("dumpState_.*\\.zip")); - return (files != null && files.length > 0) ? Arrays.stream(files) - .max(Comparator.comparingLong(File::lastModified)) - .orElse(null) : null; - } - - private void uploadFile(File file, JobParameters params) { - // Validate file size before upload - if (file.length() > Constant.MAX_FILE_SIZE) { - Log.e(TAG, "File too large: " + file.length() + " bytes (max: " + Constant.MAX_FILE_SIZE + " bytes)"); - sendErrorBroadcast("File size exceeds the maximum allowed size of 2GB"); - jobFinished(params, false); - return; - } - - // Create OkHttpClient with timeout configuration for large files - OkHttpClient client = new OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(180, TimeUnit.SECONDS) // 3 minutes timeout for large uploads - .readTimeout(60, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build(); - - Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length()); - - RequestBody fileBody = RequestBody.create(file, MediaType.parse("application/zip")); - MultipartBody requestBody = new MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart(Constant.PARAM_FILE, file.getName(), fileBody) - .addFormDataPart(Constant.PARAM_MAX_DOWNLOADS, String.valueOf(Constant.DEFAULT_MAX_DOWNLOADS)) - .addFormDataPart(Constant.PARAM_EXPIRY_HOURS, String.valueOf(Constant.DEFAULT_EXPIRY_HOURS)) - .build(); - - Request request = new Request.Builder() - .url(Constant.SERVER_URL) - .post(requestBody) - .build(); - - client.newCall(request).enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - e.printStackTrace(); - String errorMsg = "Upload failed: " + e.getMessage(); - Log.e(TAG, errorMsg); - sendErrorBroadcast(errorMsg); - jobFinished(params, false); - } - - @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); - sendSuccessBroadcast(uploadCode); - } catch (JSONException e) { - Log.e(TAG, "Failed to parse server response", e); - sendErrorBroadcast("Failed to parse server response"); - } - } else { - String errorBody = response.body().string(); - String 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 - } - - sendErrorBroadcast(errorMsg); - } - jobFinished(params, false); - } - }); - } - - private void sendSuccessBroadcast(String uploadCode) { - Intent intent = new Intent(Constant.ACTION_UPLOAD_COMPLETE); - intent.putExtra(Constant.EXTRA_UPLOAD_CODE, uploadCode); - sendBroadcast(intent); - } - - private void sendErrorBroadcast(String errorMessage) { - Intent intent = new Intent(Constant.ACTION_UPLOAD_ERROR); - intent.putExtra(Constant.EXTRA_UPLOAD_ERROR, errorMessage); - sendBroadcast(intent); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/simplelogupload/MainActivity.java b/app/src/main/java/com/mattintech/simplelogupload/MainActivity.java deleted file mode 100644 index a2ff657..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/MainActivity.java +++ /dev/null @@ -1,195 +0,0 @@ - -package com.mattintech.simplelogupload; - -import android.Manifest; -import android.app.Activity; -import android.app.job.JobInfo; -import android.app.job.JobScheduler; -import android.content.BroadcastReceiver; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.provider.Settings; -import android.view.View; -import android.widget.Button; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.RequiresApi; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; - -import java.io.File; -import java.util.Arrays; -import java.util.Comparator; - -public class MainActivity extends Activity { - private static final int REQUEST_MANAGE_EXTERNAL_STORAGE = 101; - private TextView resultView; - private Button uploadButton; - private ProgressBar progressBar; - private File mostRecentDumpStateFile; - - // BroadcastReceiver to listen for upload results - private BroadcastReceiver uploadReceiver; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - resultView = findViewById(R.id.resultView); - uploadButton = findViewById(R.id.uploadButton); - progressBar = findViewById(R.id.progressBar); - - // Check for All Files Access Permission - requestNotificationPermission(); - if (!hasAllFilesAccessPermission()) { - requestAllFilesAccessPermission(); - } else { - checkForDumpStateAndUpdateUI(); - } - - uploadButton.setOnClickListener(v -> { - if (mostRecentDumpStateFile != null) { - //scheduleJob(); - startFileUpload(); - progressBar.setVisibility(View.VISIBLE); - uploadButton.setEnabled(false); - resultView.setText("Uploading file..."); - } - }); - - // Initialize broadcast receiver - uploadReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - progressBar.setVisibility(View.GONE); - uploadButton.setEnabled(true); - - if (intent.getAction().equals(Constant.ACTION_UPLOAD_COMPLETE)) { - String uploadCode = intent.getStringExtra(Constant.EXTRA_UPLOAD_CODE); - showUploadSuccessDialog(uploadCode); - } else if (intent.getAction().equals(Constant.ACTION_UPLOAD_ERROR)) { - String errorMessage = intent.getStringExtra(Constant.EXTRA_UPLOAD_ERROR); - Toast.makeText(MainActivity.this, "Upload Error: " + errorMessage, Toast.LENGTH_LONG).show(); - resultView.setText("Upload failed: " + errorMessage); - } - } - }; - } - - @Override - protected void onResume() { - super.onResume(); - // Register the broadcast receiver - IntentFilter filter = new IntentFilter(); - filter.addAction(Constant.ACTION_UPLOAD_COMPLETE); - filter.addAction(Constant.ACTION_UPLOAD_ERROR); - registerReceiver(uploadReceiver, filter); - } - - @Override - protected void onPause() { - super.onPause(); - // Unregister the broadcast receiver - try { - unregisterReceiver(uploadReceiver); - } catch (IllegalArgumentException e) { - // Receiver not registered - } - } - - private void requestNotificationPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, 100); - } - } - } - - private void checkForDumpStateAndUpdateUI() { - // Find the most recent *DUMPSTATE*.zip file - File dir = new File(Environment.getExternalStorageDirectory(), "log"); - mostRecentDumpStateFile = findMostRecentFile(dir); - - if (mostRecentDumpStateFile != null) { - resultView.setText("Dumpstate found: " + mostRecentDumpStateFile.getName()); - uploadButton.setVisibility(View.VISIBLE); // Show the button - } else { - resultView.setText("No dumpstate file found."); - uploadButton.setVisibility(View.GONE); // Hide the button if no file found - } - } - - private File findMostRecentFile(File dir) { - File[] files = dir.listFiles((d, name) -> name.matches("dumpState_.*\\.zip")); // Regex to match dumpstate - return (files != null && files.length > 0) ? Arrays.stream(files) - .max(Comparator.comparingLong(File::lastModified)) - .orElse(null) : null; - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == REQUEST_MANAGE_EXTERNAL_STORAGE) { - if (hasAllFilesAccessPermission()) { - checkForDumpStateAndUpdateUI(); - } else { - Toast.makeText(this, "Permission not granted!", Toast.LENGTH_SHORT).show(); - } - } - } - - private boolean hasAllFilesAccessPermission() { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager(); - } - - private void requestAllFilesAccessPermission() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); - intent.setData(Uri.parse("package:" + getPackageName())); - startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE); - } catch (Exception e) { - // Fallback in case the direct intent fails (should not normally happen) - Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); - startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE); - } - } - } - - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) - private void scheduleJob() { - ComponentName componentName = new ComponentName(this, FileUploadJobService.class); - JobInfo info = new JobInfo.Builder(123, componentName) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - .setPersisted(true) - .build(); - - JobScheduler scheduler = (JobScheduler) getSystemService(JOB_SCHEDULER_SERVICE); - scheduler.schedule(info); - } - - private void startFileUpload() { - Intent serviceIntent = new Intent(this, FileUploadForegroundService.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent); - } else { - startService(serviceIntent); - } - } - - private void showUploadSuccessDialog(String uploadCode) { - UploadResultDialog dialog = new UploadResultDialog(this, uploadCode); - dialog.show(); - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/UploadResultDialog.java b/app/src/main/java/com/mattintech/simplelogupload/UploadResultDialog.java deleted file mode 100644 index dda752a..0000000 --- a/app/src/main/java/com/mattintech/simplelogupload/UploadResultDialog.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.mattintech.simplelogupload; - -import android.app.Dialog; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.os.Bundle; -import android.view.View; -import android.view.Window; -import android.widget.Button; -import android.widget.TextView; -import android.widget.Toast; - -public class UploadResultDialog extends Dialog { - private String uploadCode; - - public UploadResultDialog(Context context, String uploadCode) { - super(context); - this.uploadCode = uploadCode; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - requestWindowFeature(Window.FEATURE_NO_TITLE); - setContentView(R.layout.upload_result_dialog); - - TextView codeTextView = findViewById(R.id.uploadCode); - codeTextView.setText(uploadCode); - - Button copyButton = findViewById(R.id.copyButton); - copyButton.setOnClickListener(v -> { - ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("Upload Code", uploadCode); - clipboard.setPrimaryClip(clip); - Toast.makeText(getContext(), "Code copied to clipboard", Toast.LENGTH_SHORT).show(); - }); - } -} diff --git a/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java new file mode 100644 index 0000000..7f51c77 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/constant/AppConstants.java @@ -0,0 +1,22 @@ +package com.mattintech.simplelogupload.constant; + +/** + * General application constants + */ +public class AppConstants { + public static final String APP_TAG = "SLU::"; + + // Broadcast actions + public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE"; + public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; + + // Intent extras + public static final String EXTRA_UPLOAD_CODE = "upload_code"; + public static final String EXTRA_UPLOAD_ERROR = "upload_error"; + + // File patterns + public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip"; + + // Notification + public static final String NOTIFICATION_CHANNEL_ID = "FileUploadChannel"; +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/constant/FileConstants.java b/app/src/main/java/com/mattintech/simplelogupload/constant/FileConstants.java new file mode 100644 index 0000000..b9aad4e --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/constant/FileConstants.java @@ -0,0 +1,15 @@ +package com.mattintech.simplelogupload.constant; + +/** + * File-related constants + */ +public class FileConstants { + // File size limits + public static final long MAX_FILE_SIZE = 2L * 1024 * 1024 * 1024; // 2GB + + // File types + public static final String ZIP_EXTENSION = ".zip"; + + // File paths + public static final String LOG_DIRECTORY = "log"; +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/constant/NetworkConstants.java b/app/src/main/java/com/mattintech/simplelogupload/constant/NetworkConstants.java new file mode 100644 index 0000000..7068444 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/constant/NetworkConstants.java @@ -0,0 +1,24 @@ +package com.mattintech.simplelogupload.constant; + +/** + * Network-related constants + */ +public class NetworkConstants { + // Server constants + public static final String SERVER_URL = "http://172.16.100.167:7777/upload"; + + // Upload parameters + public static final String PARAM_FILE = "file"; + public static final String PARAM_MAX_DOWNLOADS = "max_downloads"; + public static final String PARAM_EXPIRY_HOURS = "expiry_hours"; + + // Default values + public static final int DEFAULT_MAX_DOWNLOADS = 1; + public static final int DEFAULT_EXPIRY_HOURS = 24; + public static final int MAX_EXPIRY_HOURS = 24; + + // Timeouts (in seconds) + public static final int CONNECTION_TIMEOUT = 60; + public static final int WRITE_TIMEOUT = 180; + public static final int READ_TIMEOUT = 60; +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java b/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java new file mode 100644 index 0000000..466a687 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/controller/FileController.java @@ -0,0 +1,190 @@ +package com.mattintech.simplelogupload.controller; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.constant.FileConstants; +import com.mattintech.simplelogupload.util.FileUtil; +import com.mattintech.simplelogupload.util.ZipUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; + +/** + * Controller for file operations + */ +public class FileController { + private static final String TAG = AppConstants.APP_TAG + "FileController"; + + /** + * Interface for file preparation callbacks + */ + public interface FilePrepareCallback { + void onSuccess(File preparedFile); + void onProgress(int progress); + void onError(Exception e); + } + + /** + * Find the most recent dumpstate file + * + * @return The most recent dumpstate file or null if none found + */ + public static File getMostRecentDumpStateFile() { + return FileUtil.findMostRecentDumpStateFile(); + } + + /** + * Prepare a file for upload - ensures the file is zipped + * + * @param file The file to prepare + * @param callback Callback for progress updates and completion + */ + public static void prepareFileForUpload(File file, FilePrepareCallback callback) { + if (file == null || !file.exists()) { + callback.onError(new IOException("File does not exist")); + return; + } + + // Check file size + if (!FileUtil.isValidFileSize(file)) { + callback.onError(new IOException("File size exceeds limit of " + + FileConstants.MAX_FILE_SIZE + " bytes")); + return; + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(file)) { + callback.onSuccess(file); + return; + } + + // Otherwise, compress it + try { + ZipUtil.zipFile(file, new ZipUtil.ZipProgressListener() { + @Override + public void onProgress(long bytesProcessed, long totalBytes) { + int progress = (int) ((bytesProcessed * 100) / totalBytes); + callback.onProgress(progress); + } + + @Override + public void onComplete(File zipFile) { + callback.onSuccess(zipFile); + } + + @Override + public void onError(Exception e) { + callback.onError(e); + } + }); + } catch (IOException e) { + Log.e(TAG, "Error preparing file for upload", e); + callback.onError(e); + } + } + + /** + * Prepare a file for upload asynchronously + * + * @param file The file to prepare + * @return CompletableFuture containing the prepared file + */ + public static CompletableFuture prepareFileForUploadAsync(File file) { + return CompletableFuture.supplyAsync(() -> { + try { + if (file == null || !file.exists()) { + throw new IOException("File does not exist"); + } + + // Check file size + if (!FileUtil.isValidFileSize(file)) { + throw new IOException("File size exceeds limit of " + + FileConstants.MAX_FILE_SIZE + " bytes"); + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(file)) { + return file; + } + + // Otherwise, compress it + return ZipUtil.zipFile(file); + } catch (IOException e) { + Log.e(TAG, "Error preparing file for upload", e); + throw new RuntimeException(e); + } + }, Executors.newSingleThreadExecutor()); + } + + /** + * Save a content URI to a temporary file + * + * @param context The context + * @param uri The content URI + * @param filename The desired filename + * @return The saved file + * @throws IOException If saving fails + */ + public static File saveContentUriToFile(Context context, Uri uri, String filename) throws IOException { + if (context == null || uri == null) { + throw new IOException("Invalid context or URI"); + } + + // Create a temporary file + File outputFile = new File(context.getCacheDir(), filename); + + try (InputStream inputStream = context.getContentResolver().openInputStream(uri); + FileOutputStream outputStream = new FileOutputStream(outputFile)) { + + if (inputStream == null) { + throw new IOException("Could not open input stream for URI: " + uri); + } + + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + } + + return outputFile; + } + + /** + * Validates if the file size is within the allowable limits + * + * @param file The file to validate + * @return true if the file size is valid, false otherwise + */ + public static boolean isValidFileSize(File file) { + return FileUtil.isValidFileSize(file); + } + + /** + * Save a content URI to a temporary file asynchronously + * + * @param context The context + * @param uri The content URI + * @param filename The desired filename + * @return CompletableFuture containing the saved file + */ + public static CompletableFuture saveContentUriToFileAsync( + Context context, Uri uri, String filename) { + return CompletableFuture.supplyAsync(() -> { + try { + return saveContentUriToFile(context, uri, filename); + } catch (IOException e) { + Log.e(TAG, "Error saving content URI to file", e); + throw new RuntimeException(e); + } + }, Executors.newSingleThreadExecutor()); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/ShareIntentHandler.java b/app/src/main/java/com/mattintech/simplelogupload/controller/ShareIntentHandler.java new file mode 100644 index 0000000..7c2fa83 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/controller/ShareIntentHandler.java @@ -0,0 +1,132 @@ +package com.mattintech.simplelogupload.controller; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.webkit.MimeTypeMap; + +import com.mattintech.simplelogupload.constant.AppConstants; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Controller for handling share intents + */ +public class ShareIntentHandler { + private static final String TAG = AppConstants.APP_TAG + "ShareIntentHandler"; + + /** + * Check if an intent is a share intent + * + * @param intent The intent to check + * @return true if it's a share intent, false otherwise + */ + public static boolean isShareIntent(Intent intent) { + if (intent == null) { + return false; + } + + String action = intent.getAction(); + return Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action); + } + + /** + * Extract shared URIs from an intent + * + * @param intent The share intent + * @return List of URIs or an empty list if none found + */ + public static List extractSharedUris(Intent intent) { + List uris = new ArrayList<>(); + + if (intent == null) { + return uris; + } + + String action = intent.getAction(); + String type = intent.getType(); + + if (Intent.ACTION_SEND.equals(action) && type != null) { + Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (uri != null) { + uris.add(uri); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null) { + ArrayList intentUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + if (intentUris != null) { + uris.addAll(intentUris); + } + } + + return uris; + } + + /** + * Process a shared URI and prepare it for upload + * + * @param context The context + * @param uri The URI to process + * @return CompletableFuture containing the prepared file + */ + public static CompletableFuture processSharedUri(Context context, Uri uri) { + if (context == null || uri == null) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new IllegalArgumentException("Invalid context or URI")); + return future; + } + + String filename = getFileName(context, uri); + + return FileController.saveContentUriToFileAsync(context, uri, filename) + .thenCompose(FileController::prepareFileForUploadAsync); + } + + /** + * Get the filename from a URI + * + * @param context The context + * @param uri The URI + * @return The filename or a default name if none can be determined + */ + private static String getFileName(Context context, Uri uri) { + String result = null; + + if ("content".equals(uri.getScheme())) { + ContentResolver contentResolver = context.getContentResolver(); + android.database.Cursor cursor = contentResolver.query(uri, null, null, null, null); + + try { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex("_display_name"); + if (nameIndex != -1) { + result = cursor.getString(nameIndex); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + if (result == null) { + // If no name found, use the last path segment with an extension based on mime type + result = uri.getLastPathSegment(); + + // If still null, generate a default name + if (result == null) { + String mimeType = context.getContentResolver().getType(uri); + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + result = "shared_file" + (extension != null ? "." + extension : ""); + } + } + + return result; + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java b/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java new file mode 100644 index 0000000..420518e --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/controller/UploadController.java @@ -0,0 +1,190 @@ +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 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); + + context.startService(serviceIntent); + } + + /** + * Upload a file directly and return a future with the result + * + * @param file The file to upload + * @param maxDownloads Maximum number of downloads + * @param expiryHours Expiry time in hours + * @return CompletableFuture containing the upload result + */ + public CompletableFuture uploadFile( + File file, int maxDownloads, int expiryHours) { + CompletableFuture future = new CompletableFuture<>(); + + if (file == null || !file.exists()) { + future.complete(new UploadResult("File does not exist")); + return future; + } + + // 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, "Uploading file: " + file.getName() + ", Size: " + file.length()); + + 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(); + + Request request = new Request.Builder() + .url(NetworkConstants.SERVER_URL) + .post(requestBody) + .build(); + + 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 = "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; + }); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/db/AppDatabase.java b/app/src/main/java/com/mattintech/simplelogupload/db/AppDatabase.java new file mode 100644 index 0000000..7e147f3 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/db/AppDatabase.java @@ -0,0 +1,68 @@ +package com.mattintech.simplelogupload.db; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import com.mattintech.simplelogupload.model.UploadHistory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Room database for the application + */ +@Database(entities = {UploadHistory.class}, version = 1, exportSchema = false) +public abstract class AppDatabase extends RoomDatabase { + + public abstract UploadHistoryDao uploadHistoryDao(); + + private static volatile AppDatabase INSTANCE; + private static final int NUMBER_OF_THREADS = 4; + + /** + * Executor service for database operations + */ + public static final ExecutorService databaseWriteExecutor = + Executors.newFixedThreadPool(NUMBER_OF_THREADS); + + /** + * Get the database instance + * + * @param context The application context + * @return The database instance + */ + public static AppDatabase getDatabase(final Context context) { + if (INSTANCE == null) { + synchronized (AppDatabase.class) { + if (INSTANCE == null) { + INSTANCE = Room.databaseBuilder( + context.getApplicationContext(), + AppDatabase.class, + "app_database") + .fallbackToDestructiveMigration() + .addCallback(sRoomDatabaseCallback) + .build(); + } + } + } + return INSTANCE; + } + + /** + * Callback for database creation/opening + */ + private static final RoomDatabase.Callback sRoomDatabaseCallback = new RoomDatabase.Callback() { + @Override + public void onCreate(@NonNull SupportSQLiteDatabase db) { + super.onCreate(db); + + // If you want to populate the database with initial data, + // you can do it here using databaseWriteExecutor + } + }; +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryDao.java b/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryDao.java new file mode 100644 index 0000000..f80006a --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryDao.java @@ -0,0 +1,65 @@ +package com.mattintech.simplelogupload.db; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.Query; + +import com.mattintech.simplelogupload.model.UploadHistory; + +import java.util.List; + +/** + * Data Access Object for upload history + */ +@Dao +public interface UploadHistoryDao { + /** + * Insert a new history entry + * + * @param entry The entry to insert + * @return The row ID of the inserted entry + */ + @Insert + long insert(UploadHistory entry); + + /** + * Get all history entries ordered by upload time (most recent first) + * + * @return LiveData list of history entries + */ + @Query("SELECT * FROM upload_history ORDER BY upload_time DESC") + LiveData> getAllHistory(); + + /** + * Get a history entry by ID + * + * @param id The ID of the entry + * @return The history entry + */ + @Query("SELECT * FROM upload_history WHERE id = :id") + UploadHistory getEntryById(int id); + + /** + * Delete a history entry + * + * @param entry The entry to delete + */ + @Delete + void delete(UploadHistory entry); + + /** + * Delete a history entry by ID + * + * @param id The ID of the entry to delete + */ + @Query("DELETE FROM upload_history WHERE id = :id") + void deleteById(int id); + + /** + * Delete all history entries + */ + @Query("DELETE FROM upload_history") + void deleteAll(); +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryRepository.java b/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryRepository.java new file mode 100644 index 0000000..9d02fc8 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/db/UploadHistoryRepository.java @@ -0,0 +1,90 @@ +package com.mattintech.simplelogupload.db; + +import android.app.Application; + +import androidx.lifecycle.LiveData; + +import com.mattintech.simplelogupload.model.UploadHistory; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Repository for handling upload history data operations + */ +public class UploadHistoryRepository { + private final UploadHistoryDao mUploadHistoryDao; + private final LiveData> mAllHistory; + + /** + * Constructor + * + * @param application The application + */ + public UploadHistoryRepository(Application application) { + AppDatabase db = AppDatabase.getDatabase(application); + mUploadHistoryDao = db.uploadHistoryDao(); + mAllHistory = mUploadHistoryDao.getAllHistory(); + } + + /** + * Get all history entries + * + * @return LiveData list of all history entries + */ + public LiveData> getAllHistory() { + return mAllHistory; + } + + /** + * Insert a new history entry asynchronously + * + * @param entry The entry to insert + * @return CompletableFuture containing the inserted row ID + */ + public CompletableFuture insert(final UploadHistory entry) { + return CompletableFuture.supplyAsync(() -> + mUploadHistoryDao.insert(entry), + AppDatabase.databaseWriteExecutor); + } + + /** + * Delete a history entry asynchronously + * + * @param entry The entry to delete + */ + public void delete(final UploadHistory entry) { + AppDatabase.databaseWriteExecutor.execute(() -> + mUploadHistoryDao.delete(entry)); + } + + /** + * Delete a history entry by ID asynchronously + * + * @param id The ID of the entry to delete + */ + public void deleteById(final int id) { + AppDatabase.databaseWriteExecutor.execute(() -> + mUploadHistoryDao.deleteById(id)); + } + + /** + * Delete all history entries asynchronously + */ + public void deleteAll() { + AppDatabase.databaseWriteExecutor.execute( + mUploadHistoryDao::deleteAll); + } + + /** + * Get a history entry by ID asynchronously + * + * @param id The ID of the entry + * @return CompletableFuture containing the history entry + */ + public CompletableFuture getEntryById(final int id) { + return CompletableFuture.supplyAsync(() -> + mUploadHistoryDao.getEntryById(id), + AppDatabase.databaseWriteExecutor); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java b/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java new file mode 100644 index 0000000..1604c6d --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/model/UploadHistory.java @@ -0,0 +1,109 @@ +package com.mattintech.simplelogupload.model; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Entity class for upload history + */ +@Entity(tableName = "upload_history") +public class UploadHistory { + @PrimaryKey(autoGenerate = true) + private int id; + + @ColumnInfo(name = "file_name") + private String fileName; + + @ColumnInfo(name = "upload_time") + private long uploadTime; + + @ColumnInfo(name = "upload_code") + private String uploadCode; + + @ColumnInfo(name = "max_downloads") + private int maxDownloads; + + @ColumnInfo(name = "expiry_hours") + private int expiryHours; + + /** + * Constructor + * + * @param fileName The name of the uploaded file + * @param uploadTime The time of upload (in milliseconds) + * @param uploadCode The server-provided upload code + * @param maxDownloads The maximum number of downloads allowed + * @param expiryHours The expiry time in hours + */ + public UploadHistory(String fileName, long uploadTime, String uploadCode, int maxDownloads, int expiryHours) { + this.fileName = fileName; + this.uploadTime = uploadTime; + this.uploadCode = uploadCode; + this.maxDownloads = maxDownloads; + this.expiryHours = expiryHours; + } + + // Getters and setters + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public long getUploadTime() { + return uploadTime; + } + + public void setUploadTime(long uploadTime) { + this.uploadTime = uploadTime; + } + + public String getUploadCode() { + return uploadCode; + } + + public void setUploadCode(String uploadCode) { + this.uploadCode = uploadCode; + } + + public int getMaxDownloads() { + return maxDownloads; + } + + public void setMaxDownloads(int maxDownloads) { + this.maxDownloads = maxDownloads; + } + + public int getExpiryHours() { + return expiryHours; + } + + public void setExpiryHours(int expiryHours) { + this.expiryHours = expiryHours; + } + + @NonNull + @Override + public String toString() { + return "UploadHistory{" + + "id=" + id + + ", fileName='" + fileName + '\'' + + ", uploadTime=" + uploadTime + + ", uploadCode='" + uploadCode + '\'' + + ", maxDownloads=" + maxDownloads + + ", expiryHours=" + expiryHours + + '}'; + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/model/UploadResult.java b/app/src/main/java/com/mattintech/simplelogupload/model/UploadResult.java index cb58801..b5c82fb 100644 --- a/app/src/main/java/com/mattintech/simplelogupload/model/UploadResult.java +++ b/app/src/main/java/com/mattintech/simplelogupload/model/UploadResult.java @@ -1,17 +1,45 @@ package com.mattintech.simplelogupload.model; +/** + * Model class for upload results + */ public class UploadResult { - private String code; - private String error; - - public UploadResult(String code) { + private final String code; + private final String error; + private final boolean success; + private final String fileName; + private final int maxDownloads; + private final int expiryHours; + + /** + * Constructor for successful uploads + * + * @param code The server-provided upload code + * @param fileName The name of the uploaded file + * @param maxDownloads The maximum number of downloads + * @param expiryHours The expiry time in hours + */ + public UploadResult(String code, String fileName, int maxDownloads, int expiryHours) { this.code = code; this.error = null; + this.success = true; + this.fileName = fileName; + this.maxDownloads = maxDownloads; + this.expiryHours = expiryHours; } - public UploadResult(String error, boolean isError) { + /** + * Constructor for failed uploads + * + * @param error The error message + */ + public UploadResult(String error) { this.code = null; this.error = error; + this.success = false; + this.fileName = null; + this.maxDownloads = 0; + this.expiryHours = 0; } public String getCode() { @@ -23,6 +51,18 @@ public class UploadResult { } public boolean isSuccess() { - return code != null && !code.isEmpty(); + return success; + } + + public String getFileName() { + return fileName; + } + + public int getMaxDownloads() { + return maxDownloads; + } + + public int getExpiryHours() { + return expiryHours; } } diff --git a/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java new file mode 100644 index 0000000..5fe1145 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/service/FileUploadForegroundService.java @@ -0,0 +1,241 @@ +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.view.MainActivity; + +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); + + 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 (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // For Android 14+ (API 34+) + startForeground(NOTIFICATION_ID, createInitialNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + // For Android 13 and below + startForeground(NOTIFICATION_ID, createInitialNotification()); + } + + // 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, maxDownloads, expiryHours); + } + + @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() + "..."); + + // Upload the file + 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, MainActivity.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.logupload) + .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); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/FileUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/FileUtil.java new file mode 100644 index 0000000..c8fba47 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/util/FileUtil.java @@ -0,0 +1,102 @@ +package com.mattintech.simplelogupload.util; + +import android.os.Environment; +import android.util.Log; + +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.constant.FileConstants; + +import java.io.File; +import java.util.Arrays; +import java.util.Comparator; + +/** + * Utility class for file operations + */ +public class FileUtil { + private static final String TAG = AppConstants.APP_TAG + "FileUtil"; + + /** + * Finds the most recent dumpstate file in the logs directory + * @return The most recent dumpstate file or null if none found + */ + public static File findMostRecentDumpStateFile() { + File dir = new File(Environment.getExternalStorageDirectory(), FileConstants.LOG_DIRECTORY); + return findMostRecentFileByPattern(dir, AppConstants.DUMPSTATE_FILE_PATTERN); + } + + /** + * Finds the most recent file in a directory matching the given pattern + * + * @param dir The directory to search + * @param pattern The regex pattern to match filenames + * @return The most recent matching file or null if none found + */ + public static File findMostRecentFileByPattern(File dir, String pattern) { + if (dir == null || !dir.exists() || !dir.isDirectory()) { + Log.e(TAG, "Invalid directory: " + (dir != null ? dir.getAbsolutePath() : "null")); + return null; + } + + File[] files = dir.listFiles((d, name) -> name.matches(pattern)); + + if (files == null || files.length == 0) { + Log.d(TAG, "No files matching pattern '" + pattern + "' found in " + dir.getAbsolutePath()); + return null; + } + + return Arrays.stream(files) + .max(Comparator.comparingLong(File::lastModified)) + .orElse(null); + } + + /** + * Checks if a file needs to be zipped + * @param file The file to check + * @return true if the file is already a zip, false otherwise + */ + public static boolean isZipFile(File file) { + if (file == null) { + return false; + } + return file.getName().toLowerCase().endsWith(FileConstants.ZIP_EXTENSION); + } + + /** + * Validates if the file size is within the allowable limits + * @param file The file to validate + * @return true if the file size is valid, false otherwise + */ + public static boolean isValidFileSize(File file) { + if (file == null || !file.exists()) { + return false; + } + + boolean isValid = file.length() <= FileConstants.MAX_FILE_SIZE; + + if (!isValid) { + Log.w(TAG, "File size check failed: " + file.length() + " bytes exceeds limit of " + + FileConstants.MAX_FILE_SIZE + " bytes"); + } + + return isValid; + } + + /** + * Gets the file extension from a filename + * @param filename The filename + * @return The file extension or an empty string if none + */ + public static String getFileExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return ""; + } + + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { + return ""; + } + + return filename.substring(lastDotIndex); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java new file mode 100644 index 0000000..bae034a --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/util/PermissionUtil.java @@ -0,0 +1,96 @@ +package com.mattintech.simplelogupload.util; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.Settings; +import android.util.Log; + +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.mattintech.simplelogupload.constant.AppConstants; + +/** + * Utility class for handling permissions + */ +public class PermissionUtil { + private static final String TAG = AppConstants.APP_TAG + "PermissionUtil"; + + public static final int REQUEST_MANAGE_EXTERNAL_STORAGE = 101; + public static final int REQUEST_NOTIFICATION_PERMISSION = 102; + + /** + * Checks if the app has notification permission + * + * @param context The context + * @return true if the app has notification permission, false otherwise + */ + public static boolean hasNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + // Permission is implicitly granted on older Android versions + return true; + } + + /** + * Requests notification permission + * + * @param activity The activity + */ + public static void requestNotificationPermission(Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!hasNotificationPermission(activity)) { + Log.d(TAG, "Requesting notification permission"); + ActivityCompat.requestPermissions( + activity, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + REQUEST_NOTIFICATION_PERMISSION + ); + } + } + } + + /** + * Checks if the app has all files access permission + * + * @return true if the app has all files access permission, false otherwise + */ + public static boolean hasAllFilesAccessPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return Environment.isExternalStorageManager(); + } + // Permission is handled differently on older Android versions + return true; + } + + /** + * Requests all files access permission + * + * @param activity The activity + */ + public static void requestAllFilesAccessPermission(Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!hasAllFilesAccessPermission()) { + Log.d(TAG, "Requesting all files access permission"); + try { + Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION); + intent.setData(Uri.parse("package:" + activity.getPackageName())); + activity.startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE); + } catch (Exception e) { + Log.e(TAG, "Error requesting all files access permission", e); + // Fallback in case the direct intent fails + Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + activity.startActivityForResult(intent, REQUEST_MANAGE_EXTERNAL_STORAGE); + } + } + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java b/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java new file mode 100644 index 0000000..b138581 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.java @@ -0,0 +1,124 @@ +package com.mattintech.simplelogupload.util; + +import android.util.Log; + +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.constant.FileConstants; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Utility class for zipping files + */ +public class ZipUtil { + private static final String TAG = AppConstants.APP_TAG + "ZipUtil"; + private static final int BUFFER_SIZE = 8192; // 8KB buffer + + /** + * Interface for tracking zip progress + */ + public interface ZipProgressListener { + void onProgress(long bytesProcessed, long totalBytes); + void onComplete(File zipFile); + void onError(Exception e); + } + + /** + * Compresses a file into a zip archive + * + * @param sourceFile The file to compress + * @return The compressed zip file + * @throws IOException If compression fails + */ + public static File zipFile(File sourceFile) throws IOException { + return zipFile(sourceFile, null); + } + + /** + * Compresses a file into a zip archive with progress tracking + * + * @param sourceFile The file to compress + * @param listener A listener for tracking progress (can be null) + * @return The compressed zip file + * @throws IOException If compression fails + */ + public static File zipFile(File sourceFile, ZipProgressListener listener) throws IOException { + if (sourceFile == null || !sourceFile.exists()) { + String msg = "Source file does not exist: " + (sourceFile != null ? sourceFile.getAbsolutePath() : "null"); + Log.e(TAG, msg); + throw new IOException(msg); + } + + // If already a zip file, just return it + if (FileUtil.isZipFile(sourceFile)) { + Log.d(TAG, "File is already a zip file: " + sourceFile.getName()); + if (listener != null) { + listener.onComplete(sourceFile); + } + return sourceFile; + } + + // Create a zip file in the same directory + String zipFileName = sourceFile.getName() + FileConstants.ZIP_EXTENSION; + File zipFile = new File(sourceFile.getParentFile(), zipFileName); + + Log.d(TAG, "Creating zip file: " + zipFile.getAbsolutePath()); + + try (FileOutputStream fos = new FileOutputStream(zipFile); + BufferedOutputStream bos = new BufferedOutputStream(fos); + ZipOutputStream zos = new ZipOutputStream(bos); + FileInputStream fis = new FileInputStream(sourceFile); + BufferedInputStream bis = new BufferedInputStream(fis)) { + + ZipEntry zipEntry = new ZipEntry(sourceFile.getName()); + zipEntry.setSize(sourceFile.length()); + zos.putNextEntry(zipEntry); + + byte[] buffer = new byte[BUFFER_SIZE]; + int len; + long bytesProcessed = 0; + long totalBytes = sourceFile.length(); + + while ((len = bis.read(buffer)) > 0) { + zos.write(buffer, 0, len); + bytesProcessed += len; + + if (listener != null) { + listener.onProgress(bytesProcessed, totalBytes); + } + } + + zos.closeEntry(); + + if (listener != null) { + listener.onComplete(zipFile); + } + + Log.d(TAG, "Successfully created zip file: " + zipFile.getName()); + return zipFile; + } catch (IOException e) { + Log.e(TAG, "Error creating zip file", e); + + // Clean up the partial zip file if it exists + if (zipFile.exists()) { + boolean deleted = zipFile.delete(); + if (!deleted) { + Log.w(TAG, "Failed to delete partial zip file: " + zipFile.getAbsolutePath()); + } + } + + if (listener != null) { + listener.onError(e); + } + + throw e; + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/MainActivity.java b/app/src/main/java/com/mattintech/simplelogupload/view/MainActivity.java new file mode 100644 index 0000000..f2c91ed --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/view/MainActivity.java @@ -0,0 +1,312 @@ +package com.mattintech.simplelogupload.view; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.mattintech.simplelogupload.R; +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.controller.FileController; +import com.mattintech.simplelogupload.controller.ShareIntentHandler; +import com.mattintech.simplelogupload.controller.UploadController; +import com.mattintech.simplelogupload.service.FileUploadForegroundService; +import com.mattintech.simplelogupload.util.PermissionUtil; + +import java.io.File; +import java.util.List; + +/** + * Main activity for the application + */ +public class MainActivity extends AppCompatActivity { + private static final String TAG = AppConstants.APP_TAG + "MainActivity"; + + private TextView resultView; + private Button uploadButton; + private ProgressBar progressBar; + private File selectedFile; + + private UploadController uploadController; + + // BroadcastReceiver to listen for upload results + private BroadcastReceiver uploadReceiver; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + uploadController = new UploadController(this); + + // Initialize UI elements + resultView = findViewById(R.id.resultView); + uploadButton = findViewById(R.id.uploadButton); + progressBar = findViewById(R.id.progressBar); + + // Set up button click listeners + uploadButton.setOnClickListener(v -> startUpload()); + + // Check for necessary permissions + checkAndRequestPermissions(); + + // Initialize broadcast receiver + setupBroadcastReceiver(); + + // Handle intent (e.g., from share) + handleIntent(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + } + + /** + * Handle the intent, particularly for share intents + * + * @param intent The intent to handle + */ + private void handleIntent(Intent intent) { + if (intent == null) { + return; + } + + // Check if this is a share intent + if (ShareIntentHandler.isShareIntent(intent)) { + handleShareIntent(intent); + } + } + + /** + * Handle a share intent + * + * @param intent The share intent + */ + private void handleShareIntent(Intent intent) { + List sharedUris = ShareIntentHandler.extractSharedUris(intent); + + if (sharedUris.isEmpty()) { + Toast.makeText(this, "No files shared", Toast.LENGTH_SHORT).show(); + return; + } + + // For now, just handle the first URI + Uri sharedUri = sharedUris.get(0); + + resultView.setText("Processing shared file..."); + progressBar.setVisibility(View.VISIBLE); + uploadButton.setEnabled(false); + + // Process the shared URI + ShareIntentHandler.processSharedUri(this, sharedUri) + .thenAccept(file -> { + // Save the file for upload + selectedFile = file; + runOnUiThread(() -> { + resultView.setText("File ready: " + file.getName()); + uploadButton.setEnabled(true); + uploadButton.setVisibility(View.VISIBLE); + progressBar.setVisibility(View.GONE); + }); + }) + .exceptionally(ex -> { + Log.e(TAG, "Error processing shared file", ex); + runOnUiThread(() -> { + Toast.makeText(MainActivity.this, + "Error processing file: " + ex.getMessage(), + Toast.LENGTH_LONG).show(); + resultView.setText("Error processing file"); + progressBar.setVisibility(View.GONE); + }); + return null; + }); + } + + /** + * Set up the broadcast receiver for upload results + */ + private void setupBroadcastReceiver() { + uploadReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "Received broadcast: " + intent.getAction()); + progressBar.setVisibility(View.GONE); + uploadButton.setEnabled(true); + + if (intent.getAction().equals(AppConstants.ACTION_UPLOAD_COMPLETE)) { + String uploadCode = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE); + Log.d(TAG, "Upload complete with code: " + uploadCode); + showUploadSuccessDialog(uploadCode); + } else if (intent.getAction().equals(AppConstants.ACTION_UPLOAD_ERROR)) { + String errorMessage = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR); + Log.e(TAG, "Upload error: " + errorMessage); + Toast.makeText(MainActivity.this, "Upload Error: " + errorMessage, Toast.LENGTH_LONG).show(); + resultView.setText("Upload failed: " + errorMessage); + } + } + }; + } + + @Override + protected void onResume() { + super.onResume(); + + // Check for file if we have permissions + if (PermissionUtil.hasAllFilesAccessPermission()) { + checkForDumpStateAndUpdateUI(); + } + + // Register the broadcast receiver + IntentFilter filter = new IntentFilter(); + filter.addAction(AppConstants.ACTION_UPLOAD_COMPLETE); + filter.addAction(AppConstants.ACTION_UPLOAD_ERROR); + + // Register with both regular and local broadcast managers to ensure delivery + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + registerReceiver(uploadReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(uploadReceiver, filter); + } + + // Also register with LocalBroadcastManager for reliability + LocalBroadcastManager.getInstance(this).registerReceiver(uploadReceiver, filter); + } + + @Override + protected void onPause() { + super.onPause(); + + // Unregister the broadcast receiver + try { + unregisterReceiver(uploadReceiver); + } catch (IllegalArgumentException e) { + // Receiver not registered, ignore + } + + // Also unregister from LocalBroadcastManager + LocalBroadcastManager.getInstance(this).unregisterReceiver(uploadReceiver); + } + + /** + * Check for and request necessary permissions + */ + private void checkAndRequestPermissions() { + // Request notification permission + PermissionUtil.requestNotificationPermission(this); + + // Check and request all files access permission + if (!PermissionUtil.hasAllFilesAccessPermission()) { + PermissionUtil.requestAllFilesAccessPermission(this); + } + } + + /** + * Check for dumpstate files and update the UI + */ + private void checkForDumpStateAndUpdateUI() { + // If we already have a selected file (e.g., from share), use that + if (selectedFile != null && selectedFile.exists()) { + resultView.setText("File selected: " + selectedFile.getName()); + uploadButton.setVisibility(View.VISIBLE); + return; + } + + // Otherwise, look for dumpstate files + File mostRecentDumpStateFile = FileController.getMostRecentDumpStateFile(); + + if (mostRecentDumpStateFile != null) { + selectedFile = mostRecentDumpStateFile; + resultView.setText("Dumpstate found: " + mostRecentDumpStateFile.getName()); + uploadButton.setVisibility(View.VISIBLE); + } else { + resultView.setText("No dumpstate file found."); + uploadButton.setVisibility(View.GONE); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == PermissionUtil.REQUEST_MANAGE_EXTERNAL_STORAGE) { + if (PermissionUtil.hasAllFilesAccessPermission()) { + checkForDumpStateAndUpdateUI(); + } else { + Toast.makeText(this, "Storage permission not granted!", Toast.LENGTH_SHORT).show(); + } + } + } + + /** + * Start the upload process + */ + private void startUpload() { + if (selectedFile == null || !selectedFile.exists()) { + Toast.makeText(this, "No file selected", Toast.LENGTH_SHORT).show(); + return; + } + + // Update UI + progressBar.setVisibility(View.VISIBLE); + uploadButton.setEnabled(false); + resultView.setText("Uploading file..."); + + // Start the upload service + Intent serviceIntent = new Intent(this, FileUploadForegroundService.class); + serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, selectedFile.getAbsolutePath()); + startService(serviceIntent); + } + + /** + * Show a dialog with the upload success result + * + * @param uploadCode The upload code + */ + private void showUploadSuccessDialog(String uploadCode) { + Log.d(TAG, "Opening upload success dialog with code: " + uploadCode); + runOnUiThread(() -> { + try { + UploadResultDialog dialog = new UploadResultDialog(this, uploadCode); + dialog.show(); + } catch (Exception e) { + Log.e(TAG, "Error showing upload result dialog", e); + Toast.makeText(this, "Upload successful! Code: " + uploadCode, Toast.LENGTH_LONG).show(); + } + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.action_history) { + // Open upload history activity + startActivity(new Intent(this, UploadHistoryActivity.class)); + return true; + } + + return super.onOptionsItemSelected(item); + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/UploadHistoryActivity.java b/app/src/main/java/com/mattintech/simplelogupload/view/UploadHistoryActivity.java new file mode 100644 index 0000000..399e7c0 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/view/UploadHistoryActivity.java @@ -0,0 +1,120 @@ +package com.mattintech.simplelogupload.view; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.mattintech.simplelogupload.R; +import com.mattintech.simplelogupload.constant.AppConstants; +import com.mattintech.simplelogupload.model.UploadHistory; +import com.mattintech.simplelogupload.view.adapter.UploadHistoryAdapter; +import com.mattintech.simplelogupload.viewmodel.UploadHistoryViewModel; + +import java.util.List; + +/** + * Activity for displaying upload history + */ +public class UploadHistoryActivity extends AppCompatActivity implements UploadHistoryAdapter.OnHistoryItemClickListener { + private static final String TAG = AppConstants.APP_TAG + "HistoryActivity"; + + private UploadHistoryViewModel viewModel; + private UploadHistoryAdapter adapter; + private RecyclerView recyclerView; + private TextView emptyView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_upload_history); + + // Enable "up" navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Initialize views + recyclerView = findViewById(R.id.historyRecyclerView); + emptyView = findViewById(R.id.emptyView); + + // Set up the RecyclerView + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + adapter = new UploadHistoryAdapter(this); + recyclerView.setAdapter(adapter); + + // Initialize the ViewModel + viewModel = new ViewModelProvider.AndroidViewModelFactory(getApplication()) + .create(UploadHistoryViewModel.class); + + // Observe the history data + viewModel.getAllHistory().observe(this, new Observer>() { + @Override + public void onChanged(List history) { + // Update the cached copy of the history in the adapter + adapter.setHistory(history); + + // Show/hide empty view + if (history == null || history.isEmpty()) { + recyclerView.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } else { + recyclerView.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + } + } + }); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == android.R.id.home) { + finish(); + return true; + } else if (id == R.id.action_clear_history) { + viewModel.deleteAll(); + Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void onCopyCodeClick(UploadHistory history) { + copyToClipboard(history.getUploadCode()); + } + + @Override + public void onDeleteClick(UploadHistory history) { + viewModel.delete(history); + } + + /** + * Copy text to clipboard + * + * @param text The text to copy + */ + private void copyToClipboard(String text) { + try { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("Upload Code", text); + clipboard.setPrimaryClip(clip); + Toast.makeText(this, "Code copied to clipboard", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "Error copying to clipboard", e); + Toast.makeText(this, "Failed to copy code", Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/UploadResultDialog.java b/app/src/main/java/com/mattintech/simplelogupload/view/UploadResultDialog.java new file mode 100644 index 0000000..8962735 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/view/UploadResultDialog.java @@ -0,0 +1,70 @@ +package com.mattintech.simplelogupload.view; + +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.Window; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import com.mattintech.simplelogupload.R; +import com.mattintech.simplelogupload.constant.AppConstants; + +/** + * Dialog to display upload result + */ +public class UploadResultDialog extends Dialog { + private static final String TAG = AppConstants.APP_TAG + "ResultDialog"; + + private final String uploadCode; + + /** + * Constructor + * + * @param context The context + * @param uploadCode The upload code + */ + public UploadResultDialog(Context context, String uploadCode) { + super(context); + this.uploadCode = uploadCode; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + setContentView(R.layout.upload_result_dialog); + + TextView codeTextView = findViewById(R.id.uploadCode); + codeTextView.setText(uploadCode); + + Button copyButton = findViewById(R.id.copyButton); + copyButton.setOnClickListener(v -> copyCodeToClipboard()); + + Button closeButton = findViewById(R.id.closeButton); + closeButton.setOnClickListener(v -> dismiss()); + } + + /** + * Copy the upload code to the clipboard + */ + private void copyCodeToClipboard() { + try { + ClipboardManager clipboard = (ClipboardManager) + getContext().getSystemService(Context.CLIPBOARD_SERVICE); + + ClipData clip = ClipData.newPlainText("Upload Code", uploadCode); + clipboard.setPrimaryClip(clip); + + Toast.makeText(getContext(), "Code copied to clipboard", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "Error copying to clipboard", e); + Toast.makeText(getContext(), "Failed to copy code", Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/view/adapter/UploadHistoryAdapter.java b/app/src/main/java/com/mattintech/simplelogupload/view/adapter/UploadHistoryAdapter.java new file mode 100644 index 0000000..ba191d7 --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/view/adapter/UploadHistoryAdapter.java @@ -0,0 +1,123 @@ +package com.mattintech.simplelogupload.view.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.mattintech.simplelogupload.R; +import com.mattintech.simplelogupload.model.UploadHistory; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * Adapter for displaying upload history items + */ +public class UploadHistoryAdapter extends RecyclerView.Adapter { + + private List history = new ArrayList<>(); + private final OnHistoryItemClickListener listener; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()); + + /** + * Interface for history item click events + */ + public interface OnHistoryItemClickListener { + void onCopyCodeClick(UploadHistory history); + void onDeleteClick(UploadHistory history); + } + + /** + * Constructor + * + * @param listener The click listener + */ + public UploadHistoryAdapter(OnHistoryItemClickListener listener) { + this.listener = listener; + } + + /** + * Update the history data + * + * @param history The new history data + */ + public void setHistory(List history) { + this.history = history; + notifyDataSetChanged(); + } + + @NonNull + @Override + public HistoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_upload_history, parent, false); + return new HistoryViewHolder(itemView); + } + + @Override + public void onBindViewHolder(@NonNull HistoryViewHolder holder, int position) { + UploadHistory current = history.get(position); + + holder.fileNameTextView.setText(current.getFileName()); + holder.uploadCodeTextView.setText(current.getUploadCode()); + + // Format date + String formattedDate = dateFormat.format(new Date(current.getUploadTime())); + holder.uploadTimeTextView.setText(formattedDate); + + // Set details text + String details = String.format(Locale.getDefault(), + "Max downloads: %d • Expires in %d hours", + current.getMaxDownloads(), + current.getExpiryHours()); + holder.detailsTextView.setText(details); + + // Set click listeners + holder.copyButton.setOnClickListener(v -> { + if (listener != null) { + listener.onCopyCodeClick(current); + } + }); + + holder.deleteButton.setOnClickListener(v -> { + if (listener != null) { + listener.onDeleteClick(current); + } + }); + } + + @Override + public int getItemCount() { + return history.size(); + } + + /** + * ViewHolder for history items + */ + static class HistoryViewHolder extends RecyclerView.ViewHolder { + private final TextView fileNameTextView; + private final TextView uploadCodeTextView; + private final TextView uploadTimeTextView; + private final TextView detailsTextView; + private final Button copyButton; + private final Button deleteButton; + + HistoryViewHolder(View itemView) { + super(itemView); + fileNameTextView = itemView.findViewById(R.id.fileNameText); + uploadCodeTextView = itemView.findViewById(R.id.uploadCodeText); + uploadTimeTextView = itemView.findViewById(R.id.uploadTimeText); + detailsTextView = itemView.findViewById(R.id.detailsText); + copyButton = itemView.findViewById(R.id.copyButton); + deleteButton = itemView.findViewById(R.id.deleteButton); + } + } +} diff --git a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/UploadHistoryViewModel.java b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/UploadHistoryViewModel.java new file mode 100644 index 0000000..a1e046b --- /dev/null +++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/UploadHistoryViewModel.java @@ -0,0 +1,75 @@ +package com.mattintech.simplelogupload.viewmodel; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; + +import com.mattintech.simplelogupload.db.UploadHistoryRepository; +import com.mattintech.simplelogupload.model.UploadHistory; + +import java.util.List; + +/** + * ViewModel for upload history + */ +public class UploadHistoryViewModel extends AndroidViewModel { + + private final UploadHistoryRepository repository; + private final LiveData> allHistory; + + /** + * Constructor + * + * @param application The application + */ + public UploadHistoryViewModel(@NonNull Application application) { + super(application); + repository = new UploadHistoryRepository(application); + allHistory = repository.getAllHistory(); + } + + /** + * Get all history entries + * + * @return LiveData containing all history entries + */ + public LiveData> getAllHistory() { + return allHistory; + } + + /** + * Insert a new history entry + * + * @param history The entry to insert + */ + public void insert(UploadHistory history) { + repository.insert(history); + } + + /** + * Delete a history entry + * + * @param history The entry to delete + */ + public void delete(UploadHistory history) { + repository.delete(history); + } + + /** + * Delete a history entry by ID + * + * @param id The ID of the entry to delete + */ + public void deleteById(int id) { + repository.deleteById(id); + } + + /** + * Delete all history entries + */ + public void deleteAll() { + repository.deleteAll(); + } +} diff --git a/app/src/main/res/layout/activity_upload_history.xml b/app/src/main/res/layout/activity_upload_history.xml new file mode 100644 index 0000000..45d057c --- /dev/null +++ b/app/src/main/res/layout/activity_upload_history.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_upload_history.xml b/app/src/main/res/layout/item_upload_history.xml new file mode 100644 index 0000000..72af004 --- /dev/null +++ b/app/src/main/res/layout/item_upload_history.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + +