refactoring and adding a history

This commit is contained in:
2025-04-14 22:37:20 -04:00
parent 45dc412cb7
commit b8afe35dfc
35 changed files with 2491 additions and 637 deletions

View File

@@ -1,15 +1,17 @@
plugins { plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
} }
android { android {
namespace 'com.mattintech.simplelogupload' namespace 'com.mattintech.simplelogupload'
compileSdkVersion 34 compileSdkVersion 36
defaultConfig { defaultConfig {
applicationId "com.mattintech.simplelogupload" applicationId "com.mattintech.simplelogupload"
minSdkVersion 30 minSdkVersion 30
targetSdkVersion 33 targetSdkVersion 36
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -21,14 +23,69 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 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 { dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Core Android UI
implementation 'androidx.appcompat:appcompat:1.6.1' 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.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
// Networking
implementation 'com.squareup.okhttp3:okhttp:4.9.0' 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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.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")
}
}
} }

View File

@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -18,22 +19,47 @@
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar"> android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
<!-- Main Activity -->
<activity <activity
android:name=".MainActivity" android:name=".view.MainActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Share intent filter for files -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Share intent filter for multiple files -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity> </activity>
<!-- Register the JobService --> <!-- Upload History Activity -->
<service <activity
android:name=".FileUploadJobService" android:name=".view.UploadHistoryActivity"
android:permission="android.permission.BIND_JOB_SERVICE" /> android:label="Upload History"
android:parentActivityName=".view.MainActivity"
android:exported="false">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".view.MainActivity" />
</activity>
<!-- File Upload Foreground Service -->
<service <service
android:name=".FileUploadForegroundService" android:name=".service.FileUploadForegroundService"
android:permission="android.permission.FOREGROUND_SERVICE" /> android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Uri> extractSharedUris(Intent intent) {
List<Uri> 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<Uri> 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<File> processSharedUri(Context context, Uri uri) {
if (context == null || uri == null) {
CompletableFuture<File> 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;
}
}

View File

@@ -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<UploadResult> uploadFile(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> future = new CompletableFuture<>();
if (file == null || !file.exists()) {
future.complete(new UploadResult("File does not exist"));
return future;
}
// 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;
});
}
}

View File

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

View File

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

View File

@@ -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<List<UploadHistory>> 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<List<UploadHistory>> getAllHistory() {
return mAllHistory;
}
/**
* Insert a new history entry asynchronously
*
* @param entry The entry to insert
* @return CompletableFuture containing the inserted row ID
*/
public CompletableFuture<Long> 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<UploadHistory> getEntryById(final int id) {
return CompletableFuture.supplyAsync(() ->
mUploadHistoryDao.getEntryById(id),
AppDatabase.databaseWriteExecutor);
}
}

View File

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

View File

@@ -1,17 +1,45 @@
package com.mattintech.simplelogupload.model; package com.mattintech.simplelogupload.model;
/**
* Model class for upload results
*/
public class UploadResult { public class UploadResult {
private String code; private final String code;
private String error; private final String error;
private final boolean success;
private final String fileName;
private final int maxDownloads;
private final int expiryHours;
public UploadResult(String code) { /**
* 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.code = code;
this.error = null; 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.code = null;
this.error = error; this.error = error;
this.success = false;
this.fileName = null;
this.maxDownloads = 0;
this.expiryHours = 0;
} }
public String getCode() { public String getCode() {
@@ -23,6 +51,18 @@ public class UploadResult {
} }
public boolean isSuccess() { 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;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<List<UploadHistory>>() {
@Override
public void onChanged(List<UploadHistory> 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();
}
}
}

View File

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

View File

@@ -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<UploadHistoryAdapter.HistoryViewHolder> {
private List<UploadHistory> 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<UploadHistory> 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);
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".view.UploadHistoryActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/historyRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
android:padding="8dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="No upload history"
android:textSize="18sp"
android:visibility="gone" />
</RelativeLayout>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/fileNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/uploadTimeText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="#757575"
android:layout_marginTop="4dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Code: "
android:textStyle="bold" />
<TextView
android:id="@+id/uploadCodeText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textIsSelectable="true"
android:textStyle="bold"
android:textColor="#0066CC" />
</LinearLayout>
<TextView
android:id="@+id/detailsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Button
android:id="@+id/copyButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Copy Code"
style="@style/Widget.AppCompat.Button.Colored" />
<Button
android:id="@+id/deleteButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="Delete"
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -44,12 +44,27 @@
<Button <Button
android:id="@+id/copyButton" android:id="@+id/copyButton"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginEnd="4dp"
android:text="Copy Code" android:text="Copy Code"
app:layout_constraintTop_toBottomOf="@id/uploadCode" app:layout_constraintTop_toBottomOf="@id/uploadCode"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toStartOf="@id/closeButton"
app:layout_constraintHorizontal_weight="1" />
<Button
android:id="@+id/closeButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="4dp"
android:text="Close"
style="@style/Widget.AppCompat.Button.Borderless.Colored"
app:layout_constraintTop_toBottomOf="@id/uploadCode"
app:layout_constraintStart_toEndOf="@id/copyButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_weight="1" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_clear_history"
android:title="Clear History"
android:orderInCategory="100"
android:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_history"
android:title="Upload History"
android:orderInCategory="100"
android:showAsAction="never" />
</menu>

View File

@@ -1,12 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.3.1' // Updated to 8.0.0 classpath 'com.android.tools.build:gradle:8.9.1' // More stable version
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
@@ -15,4 +17,17 @@ allprojects {
google() google()
mavenCentral() mavenCentral()
} }
// Apply to all projects
configurations.all {
resolutionStrategy {
// Force a specific version of Kotlin stdlib
force "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
}
}
tasks.register('clean', Delete) {
delete rootProject.buildDir
} }

View File

@@ -1 +1,15 @@
android.suppressUnsupportedCompileSdk=36
android.useAndroidX=true android.useAndroidX=true
# JVM arguments for kapt with JDK 9+
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 \
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.treescanner=ALL-UNNAMED

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME