support deleting files from the app and specify upload params

This commit is contained in:
2025-04-15 18:35:47 -04:00
parent 05351ce364
commit 111ac2ee94
12 changed files with 668 additions and 60 deletions

View File

@@ -208,4 +208,64 @@ public class UploadController {
return null;
});
}
/**
* Delete a file from the server using its upload code
*
* @param uploadCode The upload code of the file to delete
* @return CompletableFuture containing a boolean indicating success or failure
*/
public CompletableFuture<Boolean> deleteFileFromServer(String uploadCode) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
if (uploadCode == null || uploadCode.isEmpty()) {
Log.e(TAG, "Cannot delete file with empty upload code");
future.complete(false);
return future;
}
// Create OkHttpClient with timeout configuration
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(NetworkConstants.CONNECTION_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(NetworkConstants.READ_TIMEOUT, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
// Get server base URL and client key from preferences
String serverBaseUrl = PreferencesUtil.getServerBaseUrl(context);
String clientKey = PreferencesUtil.getClientKey(context);
// Create the delete URL
String deleteUrl = serverBaseUrl + "/delete/" + uploadCode;
Log.d(TAG, "Deleting file from server: " + deleteUrl);
// Build request with client-key header
Request request = new Request.Builder()
.url(deleteUrl)
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
.delete()
.build();
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "Server delete failed", e);
future.complete(false);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (response.isSuccessful()) {
Log.d(TAG, "Server delete successful for code: " + uploadCode);
future.complete(true);
} else {
String errorBody = response.body().string();
Log.e(TAG, "Server delete failed with code: " + response.code() + ", Body: " + errorBody);
future.complete(false);
}
}
});
return future;
}
}

View File

@@ -32,6 +32,14 @@ public interface UploadHistoryDao {
@Query("SELECT * FROM upload_history ORDER BY upload_time DESC")
LiveData<List<UploadHistory>> getAllHistory();
/**
* Get all history entries ordered by upload time (most recent first) as a direct list
*
* @return List of history entries
*/
@Query("SELECT * FROM upload_history ORDER BY upload_time DESC")
List<UploadHistory> getAllHistoryDirect();
/**
* Get a history entry by ID
*

View File

@@ -36,6 +36,17 @@ public class UploadHistoryRepository {
return mAllHistory;
}
/**
* Get all history entries as a one-time operation
*
* @return CompletableFuture containing a list of all history entries
*/
public CompletableFuture<List<UploadHistory>> getAllHistoryOneTime() {
return CompletableFuture.supplyAsync(() ->
mUploadHistoryDao.getAllHistoryDirect(),
AppDatabase.databaseWriteExecutor);
}
/**
* Insert a new history entry asynchronously
*

View File

@@ -60,6 +60,15 @@ public class FileUploadForegroundService extends Service {
int maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS);
int expiryHours = intent.getIntExtra(EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS);
// Log parameters for debugging
Log.d(TAG, "Upload parameters - Max Downloads: " + maxDownloads + ", Expiry Hours: " + expiryHours);
// Validate expiry hours
if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) {
Log.w(TAG, "Expiry hours exceeded maximum allowed value, capping at " + NetworkConstants.MAX_EXPIRY_HOURS);
expiryHours = NetworkConstants.MAX_EXPIRY_HOURS;
}
if (filePath == null) {
Log.e(TAG, "No file path provided");
// Try to find the most recent dump state file
@@ -91,12 +100,16 @@ public class FileUploadForegroundService extends Service {
startForeground(NOTIFICATION_ID, createInitialNotification());
}
// Create final copies for use in the callback
final int finalMaxDownloads = maxDownloads;
final int finalExpiryHours = expiryHours;
// Prepare file for upload
FileController.prepareFileForUpload(file, new FileController.FilePrepareCallback() {
@Override
public void onSuccess(File preparedFile) {
Log.d(TAG, "File prepared for upload: " + preparedFile.getName());
uploadFile(preparedFile, maxDownloads, expiryHours);
uploadFile(preparedFile, finalMaxDownloads, finalExpiryHours);
}
@Override

View File

@@ -91,6 +91,18 @@ public class PreferencesUtil {
getPreferences(context).edit().putString(KEY_CLIENT_KEY, clientKey).apply();
}
/**
* Get the server base URL (address + port) without any path
*
* @param context The application context
* @return The server base URL
*/
public static String getServerBaseUrl(Context context) {
String address = getServerAddress(context);
int port = getServerPort(context);
return "http://" + address + ":" + port;
}
/**
* Get the complete server URL (address + port + path)
*
@@ -98,9 +110,7 @@ public class PreferencesUtil {
* @return The complete server URL
*/
public static String getServerUrl(Context context) {
String address = getServerAddress(context);
int port = getServerPort(context);
return "http://" + address + ":" + port + "/upload";
return getServerBaseUrl(context) + "/upload";
}
/**

View File

@@ -252,34 +252,45 @@ public class MainActivity extends AppCompatActivity {
List<File> selectedFiles = dumpstateAdapter.getSelectedFiles();
int fileCount = selectedFiles.size();
// Update UI
progressBar.setVisibility(View.VISIBLE);
uploadDumpstateButton.setEnabled(false);
dumpstateStatusText.setText(getString(R.string.preparing_multiple_files, fileCount));
// Prepare and upload multiple files
FileController.prepareMultipleFilesForUploadAsync(selectedFiles, this)
.thenAccept(zipFile -> {
Log.d(TAG, "Multiple files zipped successfully: " + zipFile.getAbsolutePath());
runOnUiThread(() -> {
dumpstateStatusText.setText(getString(R.string.uploading_multiple_files, fileCount));
});
// Upload the zip file
startUploadService(zipFile);
})
.exceptionally(ex -> {
Log.e(TAG, "Error preparing multiple files", ex);
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
uploadDumpstateButton.setEnabled(true);
Toast.makeText(MainActivity.this,
getString(R.string.file_error, ex.getMessage()),
Toast.LENGTH_LONG).show();
});
return null;
});
// Show upload parameters dialog first
new UploadParamsDialog(this, new UploadParamsDialog.UploadParamsListener() {
@Override
public void onUploadParamsSet(int maxDownloads, int expiryHours) {
// Update UI
progressBar.setVisibility(View.VISIBLE);
uploadDumpstateButton.setEnabled(false);
dumpstateStatusText.setText(getString(R.string.preparing_multiple_files, fileCount));
// Prepare and upload multiple files
FileController.prepareMultipleFilesForUploadAsync(selectedFiles, MainActivity.this)
.thenAccept(zipFile -> {
Log.d(TAG, "Multiple files zipped successfully: " + zipFile.getAbsolutePath());
runOnUiThread(() -> {
dumpstateStatusText.setText(getString(R.string.uploading_multiple_files, fileCount));
});
// Upload the zip file with parameters
startUploadService(zipFile, maxDownloads, expiryHours);
})
.exceptionally(ex -> {
Log.e(TAG, "Error preparing multiple files", ex);
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
uploadDumpstateButton.setEnabled(true);
Toast.makeText(MainActivity.this,
getString(R.string.file_error, ex.getMessage()),
Toast.LENGTH_LONG).show();
});
return null;
});
}
@Override
public void onUploadParamsCancelled() {
// User cancelled, do nothing
}
}).show();
}
@Override
@@ -389,13 +400,8 @@ public class MainActivity extends AppCompatActivity {
return;
}
// Update UI
progressBar.setVisibility(View.VISIBLE);
uploadSelectedFileButton.setEnabled(false);
selectedFileText.setText(getString(R.string.uploading_file, selectedFile.getName()));
// Start the upload service
startUploadService(selectedFile);
// Show upload parameters dialog
showUploadParamsDialog(selectedFile);
}
/**
@@ -413,7 +419,7 @@ public class MainActivity extends AppCompatActivity {
if (dumpstateFiles.length == 1) {
// Only one dumpstate file, use it directly
dumpstateFile = dumpstateFiles[0];
startDumpstateUpload(dumpstateFile);
showUploadParamsDialog(dumpstateFile);
} else {
// Multiple dumpstate files are available, suggest expanding the list
if (!isDumpstateListExpanded) {
@@ -421,7 +427,7 @@ public class MainActivity extends AppCompatActivity {
} else {
// If list is already expanded, use the first file
dumpstateFile = dumpstateFiles[0];
startDumpstateUpload(dumpstateFile);
showUploadParamsDialog(dumpstateFile);
}
}
}
@@ -430,8 +436,10 @@ public class MainActivity extends AppCompatActivity {
* Start uploading the selected dumpstate file
*
* @param file The dumpstate file to upload
* @param maxDownloads The maximum number of downloads
* @param expiryHours The expiry time in hours
*/
private void startDumpstateUpload(File file) {
private void startDumpstateUpload(File file, int maxDownloads, int expiryHours) {
if (file == null || !file.exists()) {
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
return;
@@ -442,22 +450,57 @@ public class MainActivity extends AppCompatActivity {
uploadDumpstateButton.setEnabled(false);
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
// Start the upload service
startUploadService(file);
// Start the upload service with parameters
startUploadService(file, maxDownloads, expiryHours);
}
/**
* Start the upload service with the given file
* Start the upload service with the given file and parameters
*
* @param file The file to upload
* @param maxDownloads The maximum number of downloads
* @param expiryHours The expiry time in hours
*/
private void startUploadService(File file) {
private void startUploadService(File file, int maxDownloads, int expiryHours) {
// Start the upload service
Intent serviceIntent = new Intent(this, FileUploadForegroundService.class);
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, file.getAbsolutePath());
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, maxDownloads);
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_EXPIRY_HOURS, expiryHours);
startService(serviceIntent);
}
/**
* Show the upload parameters dialog
*
* @param file The file to upload
*/
private void showUploadParamsDialog(File file) {
new UploadParamsDialog(this, new UploadParamsDialog.UploadParamsListener() {
@Override
public void onUploadParamsSet(int maxDownloads, int expiryHours) {
// Update UI
progressBar.setVisibility(View.VISIBLE);
uploadSelectedFileButton.setEnabled(false);
if (file == selectedFile) {
selectedFileText.setText(getString(R.string.uploading_file, file.getName()));
} else if (file == dumpstateFile) {
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
uploadDumpstateButton.setEnabled(false);
}
// Start the upload service with parameters
startUploadService(file, maxDownloads, expiryHours);
}
@Override
public void onUploadParamsCancelled() {
// User cancelled, do nothing
}
}).show();
}
/**
* Update the selected file UI
*

View File

@@ -10,6 +10,8 @@ import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
@@ -73,6 +75,18 @@ public class UploadHistoryActivity extends AppCompatActivity implements UploadHi
}
}
});
// Observe the delete status to provide feedback
viewModel.getDeleteStatus().observe(this, deleteSuccess -> {
if (deleteSuccess != null) {
if (deleteSuccess) {
Toast.makeText(this, "File deleted from server successfully", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "Failed to delete file from server, but removed from local history",
Toast.LENGTH_LONG).show();
}
}
});
}
@Override
@@ -83,8 +97,30 @@ public class UploadHistoryActivity extends AppCompatActivity implements UploadHi
finish();
return true;
} else if (id == R.id.action_clear_history) {
viewModel.deleteAll();
Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show();
// Show confirmation dialog
new AlertDialog.Builder(this)
.setTitle("Clear All History")
.setMessage("Do you want to delete all uploads from both local history and the server?")
.setPositiveButton("Yes", (dialog, which) -> {
// Show progress dialog
AlertDialog progressDialog = new AlertDialog.Builder(this)
.setTitle("Deleting...")
.setMessage("Attempting to delete all files from server and clear history.")
.setCancelable(false)
.create();
progressDialog.show();
// Attempt to delete all
viewModel.deleteAll()
.thenAccept(unused -> {
runOnUiThread(() -> {
progressDialog.dismiss();
// Toast message will be shown through the observer
});
});
})
.setNegativeButton("No", null)
.show();
return true;
}
@@ -98,7 +134,29 @@ public class UploadHistoryActivity extends AppCompatActivity implements UploadHi
@Override
public void onDeleteClick(UploadHistory history) {
viewModel.delete(history);
// Show confirmation dialog
new AlertDialog.Builder(this)
.setTitle("Delete Upload")
.setMessage("Do you want to delete this upload from both local history and the server?")
.setPositiveButton("Yes", (dialog, which) -> {
// Show progress dialog
AlertDialog progressDialog = new AlertDialog.Builder(this)
.setTitle("Deleting...")
.setMessage("Attempting to delete from server and local history.")
.setCancelable(false)
.create();
progressDialog.show();
// Attempt to delete
viewModel.delete(history)
.thenAccept(unused -> {
runOnUiThread(() -> {
progressDialog.dismiss();
});
});
})
.setNegativeButton("No", null)
.show();
}
/**

View File

@@ -0,0 +1,127 @@
package com.mattintech.simplelogupload.view;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.Window;
import android.widget.Button;
import android.widget.EditText;
import androidx.annotation.NonNull;
import com.mattintech.simplelogupload.R;
import com.mattintech.simplelogupload.constant.NetworkConstants;
/**
* Dialog for setting upload parameters
*/
public class UploadParamsDialog extends Dialog {
private EditText maxDownloadsInput;
private EditText expiryTimeInput;
private final UploadParamsListener listener;
/**
* Interface for upload parameter callbacks
*/
public interface UploadParamsListener {
void onUploadParamsSet(int maxDownloads, int expiryHours);
void onUploadParamsCancelled();
}
/**
* Constructor
*
* @param context The context
* @param listener Callback listener
*/
public UploadParamsDialog(@NonNull Context context, UploadParamsListener listener) {
super(context);
this.listener = listener;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.dialog_upload_params);
// Initialize views
maxDownloadsInput = findViewById(R.id.maxDownloadsInput);
expiryTimeInput = findViewById(R.id.expiryTimeInput);
// Set default values as hints
maxDownloadsInput.setHint("Default: " + NetworkConstants.DEFAULT_MAX_DOWNLOADS);
expiryTimeInput.setHint("Default: " + NetworkConstants.DEFAULT_EXPIRY_HOURS);
// Set up buttons
Button confirmButton = findViewById(R.id.confirmButton);
Button cancelButton = findViewById(R.id.cancelButton);
confirmButton.setOnClickListener(v -> {
int maxDownloads = getMaxDownloadsValue();
int expiryHours = getExpiryHoursValue();
// Validate expiry time
if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) {
expiryTimeInput.setError("Maximum expiry time is " +
NetworkConstants.MAX_EXPIRY_HOURS + " hours");
return;
}
// Validate max downloads (must be non-negative)
if (maxDownloads < 0) {
maxDownloadsInput.setError("Downloads must be 0 or greater");
return;
}
if (listener != null) {
listener.onUploadParamsSet(maxDownloads, expiryHours);
}
dismiss();
});
cancelButton.setOnClickListener(v -> {
if (listener != null) {
listener.onUploadParamsCancelled();
}
dismiss();
});
}
/**
* Get the max downloads value from the input
*
* @return The max downloads value (default if not set)
*/
private int getMaxDownloadsValue() {
if (TextUtils.isEmpty(maxDownloadsInput.getText())) {
return NetworkConstants.DEFAULT_MAX_DOWNLOADS;
}
try {
return Integer.parseInt(maxDownloadsInput.getText().toString());
} catch (NumberFormatException e) {
return NetworkConstants.DEFAULT_MAX_DOWNLOADS;
}
}
/**
* Get the expiry hours value from the input
*
* @return The expiry hours value (default if not set)
*/
private int getExpiryHoursValue() {
if (TextUtils.isEmpty(expiryTimeInput.getText())) {
return NetworkConstants.DEFAULT_EXPIRY_HOURS;
}
try {
return Integer.parseInt(expiryTimeInput.getText().toString());
} catch (NumberFormatException e) {
return NetworkConstants.DEFAULT_EXPIRY_HOURS;
}
}
}

View File

@@ -5,11 +5,14 @@ import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.mattintech.simplelogupload.controller.UploadController;
import com.mattintech.simplelogupload.db.UploadHistoryRepository;
import com.mattintech.simplelogupload.model.UploadHistory;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* ViewModel for upload history
@@ -17,7 +20,9 @@ import java.util.List;
public class UploadHistoryViewModel extends AndroidViewModel {
private final UploadHistoryRepository repository;
private final UploadController uploadController;
private final LiveData<List<UploadHistory>> allHistory;
private final MutableLiveData<Boolean> deleteStatus = new MutableLiveData<>();
/**
* Constructor
@@ -27,6 +32,7 @@ public class UploadHistoryViewModel extends AndroidViewModel {
public UploadHistoryViewModel(@NonNull Application application) {
super(application);
repository = new UploadHistoryRepository(application);
uploadController = new UploadController(application);
allHistory = repository.getAllHistory();
}
@@ -49,12 +55,42 @@ public class UploadHistoryViewModel extends AndroidViewModel {
}
/**
* Delete a history entry
* Get the delete status LiveData
*
* @return LiveData containing the delete status
*/
public LiveData<Boolean> getDeleteStatus() {
return deleteStatus;
}
/**
* Delete a history entry and the associated file from the server
*
* @param history The entry to delete
* @return CompletableFuture for the operation
*/
public void delete(UploadHistory history) {
repository.delete(history);
public CompletableFuture<Void> delete(UploadHistory history) {
CompletableFuture<Void> future = new CompletableFuture<>();
// First attempt to delete from server
uploadController.deleteFileFromServer(history.getUploadCode())
.thenAccept(success -> {
// Update the status
deleteStatus.postValue(success);
// Always delete from local database, even if server delete fails
repository.delete(history);
future.complete(null);
})
.exceptionally(ex -> {
// On exception, still delete from local database
deleteStatus.postValue(false);
repository.delete(history);
future.complete(null);
return null;
});
return future;
}
/**
@@ -67,9 +103,76 @@ public class UploadHistoryViewModel extends AndroidViewModel {
}
/**
* Delete all history entries
* Delete all history entries and attempt to delete all files from server
*
* @return CompletableFuture for the operation
*/
public void deleteAll() {
repository.deleteAll();
public CompletableFuture<Void> deleteAll() {
CompletableFuture<Void> future = new CompletableFuture<>();
// First get all history entries
repository.getAllHistoryOneTime()
.thenAccept(historyList -> {
if (historyList == null || historyList.isEmpty()) {
// No entries to delete
repository.deleteAll();
future.complete(null);
return;
}
// Counter for tracking completion
final int[] completedCount = {0};
final int totalCount = historyList.size();
final boolean[] anyServerSuccess = {false};
// Process each entry
for (UploadHistory history : historyList) {
uploadController.deleteFileFromServer(history.getUploadCode())
.thenAccept(success -> {
if (success) {
anyServerSuccess[0] = true;
}
// Increment counter
completedCount[0]++;
// Check if all operations are complete
if (completedCount[0] >= totalCount) {
// All server delete attempts completed, now delete local entries
repository.deleteAll();
// Update status based on overall success
deleteStatus.postValue(anyServerSuccess[0]);
future.complete(null);
}
})
.exceptionally(ex -> {
// Count failed operations too
completedCount[0]++;
// Check if all operations are complete
if (completedCount[0] >= totalCount) {
// All server delete attempts completed, now delete local entries
repository.deleteAll();
// Update status based on overall success
deleteStatus.postValue(anyServerSuccess[0]);
future.complete(null);
}
return null;
});
}
})
.exceptionally(ex -> {
// If we can't get the history, just delete local entries
repository.deleteAll();
deleteStatus.postValue(false);
future.complete(null);
return null;
});
return future;
}
}

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/upload_params_title"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/max_downloads_label"
android:textSize="16sp" />
<EditText
android:id="@+id/maxDownloadsInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:inputType="number"
android:hint="@string/max_downloads_hint"
android:importantForAutofill="no" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/max_downloads_note"
android:textSize="12sp"
android:textStyle="italic"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/expiry_time_label"
android:textSize="16sp" />
<EditText
android:id="@+id/expiryTimeInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:inputType="number"
android:hint="@string/expiry_time_hint"
android:importantForAutofill="no" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/expiry_time_note"
android:textSize="12sp"
android:textStyle="italic"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/cancelButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cancel_button"
style="@style/Widget.AppCompat.Button.Borderless" />
<Button
android:id="@+id/confirmButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/upload_button" />
</LinearLayout>
</LinearLayout>

View File

@@ -56,4 +56,15 @@
<string name="no_dumpstates_selected">No files selected</string>
<string name="preparing_multiple_files">Preparing %1$d files for upload…</string>
<string name="uploading_multiple_files">Uploading %1$d files as one package</string>
<!-- Upload Parameters Dialog -->
<string name="upload_params_title">Upload Parameters</string>
<string name="max_downloads_label">Maximum Downloads:</string>
<string name="max_downloads_hint">Default: 1</string>
<string name="max_downloads_note">Set to 0 for unlimited downloads</string>
<string name="expiry_time_label">Expiry Time (hours):</string>
<string name="expiry_time_hint">Default: 24</string>
<string name="expiry_time_note">Maximum 24 hours (1 day)</string>
<string name="cancel_button">Cancel</string>
<string name="upload_button">Upload</string>
</resources>