support deleting files from the app and specify upload params
This commit is contained in:
77
README.md
77
README.md
@@ -15,8 +15,12 @@ Note - this requires that you leverage SimpleFileUpload-Server
|
|||||||
- Copy codes for easy sharing
|
- Copy codes for easy sharing
|
||||||
- Support for Android 14+ with proper foreground service implementation
|
- Support for Android 14+ with proper foreground service implementation
|
||||||
- MVC architecture for clean code separation
|
- MVC architecture for clean code separation
|
||||||
|
- Custom upload parameters (expiry time, download limit)
|
||||||
|
- Server-side file deletion from history
|
||||||
|
|
||||||
## Recent Updates
|
## Recent Updates
|
||||||
|
- Added server-side file deletion when removing items from history
|
||||||
|
- Added custom upload parameters for maximum downloads and expiry time
|
||||||
- Added multi-select capability for dumpstate files
|
- Added multi-select capability for dumpstate files
|
||||||
- Implemented zipping multiple files into a single package for upload
|
- Implemented zipping multiple files into a single package for upload
|
||||||
- Added in-place expandable file list to avoid popup dialogs
|
- Added in-place expandable file list to avoid popup dialogs
|
||||||
@@ -31,14 +35,77 @@ Note - this requires that you leverage SimpleFileUpload-Server
|
|||||||
|
|
||||||
## How to Use
|
## How to Use
|
||||||
1. **Upload a File**: Tap "Select File from Storage" to pick any file from your device.
|
1. **Upload a File**: Tap "Select File from Storage" to pick any file from your device.
|
||||||
2. **Upload a Dumpstate**: The app automatically detects Samsung dumpstate files on your device.
|
2. **Set Upload Parameters**: When uploading, set custom parameters such as maximum downloads allowed and expiry time.
|
||||||
3. **Multi-select Mode**: When multiple dumpstate files are detected, tap "Show Files" to view and select multiple files for upload.
|
3. **Upload a Dumpstate**: The app automatically detects Samsung dumpstate files on your device.
|
||||||
4. **Select Files**: Check multiple files to upload them as a single package.
|
4. **Multi-select Mode**: When multiple dumpstate files are detected, tap "Show Files" to view and select multiple files for upload.
|
||||||
5. **View History**: Access your upload history from the menu to find previously uploaded file codes.
|
5. **Select Files**: Check multiple files to upload them as a single package.
|
||||||
|
6. **View History**: Access your upload history from the menu to find previously uploaded file codes.
|
||||||
|
7. **Delete From Server**: When deleting items from history, files will also be removed from the server.
|
||||||
|
|
||||||
## Upcoming Features
|
## Upcoming Features
|
||||||
- Progress tracking for large files
|
- Progress tracking for large files
|
||||||
- Dark mode support
|
- Dark mode support
|
||||||
- Upload history filtering and search
|
- Upload history filtering and search
|
||||||
- QR code generation for quick sharing
|
- QR code generation for quick sharing
|
||||||
- Custom upload parameters (expiry time, download limit)
|
|
||||||
|
## Feature Details
|
||||||
|
|
||||||
|
### Custom Upload Parameters
|
||||||
|
|
||||||
|
The app allows you to customize how your uploads are handled on the server:
|
||||||
|
|
||||||
|
#### Maximum Downloads
|
||||||
|
|
||||||
|
- **Purpose**: Limit how many times a file can be downloaded
|
||||||
|
- **Default**: 1 download (file is removed after first download)
|
||||||
|
- **Options**:
|
||||||
|
- Set to any positive number to limit downloads
|
||||||
|
- Set to 0 for unlimited downloads
|
||||||
|
- **How to use**: Enter your desired number in the "Maximum Downloads" field when uploading a file
|
||||||
|
|
||||||
|
#### Expiry Time
|
||||||
|
|
||||||
|
- **Purpose**: Set how long the file remains available on the server
|
||||||
|
- **Default**: 24 hours (1 day)
|
||||||
|
- **Maximum**: 24 hours (server-enforced limit)
|
||||||
|
- **How to use**: Enter your desired number of hours in the "Expiry Time" field when uploading
|
||||||
|
|
||||||
|
#### Upload Parameter Dialog
|
||||||
|
|
||||||
|
When you initiate an upload (either a selected file or dumpstate), the app will present a dialog where you can set these parameters before the actual upload begins. If you don't modify the fields, the default values will be used.
|
||||||
|
|
||||||
|
### Server-Side File Deletion
|
||||||
|
|
||||||
|
The app integrates with the server's delete endpoint to remove files from the server when you delete them from your local history.
|
||||||
|
|
||||||
|
#### Single Item Deletion
|
||||||
|
|
||||||
|
When you delete a single item from your upload history:
|
||||||
|
1. The app attempts to delete the file from the server using its upload code
|
||||||
|
2. Regardless of server response, the item is removed from your local history
|
||||||
|
3. You will receive feedback about whether the server deletion was successful
|
||||||
|
|
||||||
|
#### Bulk Deletion (Clear History)
|
||||||
|
|
||||||
|
When you clear all history:
|
||||||
|
1. The app attempts to delete each file from the server
|
||||||
|
2. All items are removed from your local history
|
||||||
|
3. You will receive feedback about whether any of the server deletions were successful
|
||||||
|
|
||||||
|
#### Benefits
|
||||||
|
|
||||||
|
- **Privacy**: Ensures your uploaded files don't remain on the server indefinitely
|
||||||
|
- **Resource Management**: Helps keep the server storage clean
|
||||||
|
- **Control**: Gives you full control over the lifecycle of your uploaded files
|
||||||
|
|
||||||
|
#### API Integration
|
||||||
|
|
||||||
|
The app uses the server's delete endpoint:
|
||||||
|
```
|
||||||
|
DELETE /delete/<code>
|
||||||
|
```
|
||||||
|
|
||||||
|
This endpoint requires the same client authentication key used for uploads. The server returns:
|
||||||
|
- 200 OK: File successfully deleted
|
||||||
|
- 404 Not Found: File doesn't exist (already deleted or expired)
|
||||||
|
- 401 Unauthorized: Invalid client key
|
||||||
|
|||||||
@@ -208,4 +208,64 @@ public class UploadController {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ public interface UploadHistoryDao {
|
|||||||
@Query("SELECT * FROM upload_history ORDER BY upload_time DESC")
|
@Query("SELECT * FROM upload_history ORDER BY upload_time DESC")
|
||||||
LiveData<List<UploadHistory>> getAllHistory();
|
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
|
* Get a history entry by ID
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ public class UploadHistoryRepository {
|
|||||||
return mAllHistory;
|
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
|
* Insert a new history entry asynchronously
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ public class FileUploadForegroundService extends Service {
|
|||||||
int maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS);
|
int maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS);
|
||||||
int expiryHours = intent.getIntExtra(EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS);
|
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) {
|
if (filePath == null) {
|
||||||
Log.e(TAG, "No file path provided");
|
Log.e(TAG, "No file path provided");
|
||||||
// Try to find the most recent dump state file
|
// Try to find the most recent dump state file
|
||||||
@@ -91,12 +100,16 @@ public class FileUploadForegroundService extends Service {
|
|||||||
startForeground(NOTIFICATION_ID, createInitialNotification());
|
startForeground(NOTIFICATION_ID, createInitialNotification());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create final copies for use in the callback
|
||||||
|
final int finalMaxDownloads = maxDownloads;
|
||||||
|
final int finalExpiryHours = expiryHours;
|
||||||
|
|
||||||
// Prepare file for upload
|
// Prepare file for upload
|
||||||
FileController.prepareFileForUpload(file, new FileController.FilePrepareCallback() {
|
FileController.prepareFileForUpload(file, new FileController.FilePrepareCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(File preparedFile) {
|
public void onSuccess(File preparedFile) {
|
||||||
Log.d(TAG, "File prepared for upload: " + preparedFile.getName());
|
Log.d(TAG, "File prepared for upload: " + preparedFile.getName());
|
||||||
uploadFile(preparedFile, maxDownloads, expiryHours);
|
uploadFile(preparedFile, finalMaxDownloads, finalExpiryHours);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ public class PreferencesUtil {
|
|||||||
getPreferences(context).edit().putString(KEY_CLIENT_KEY, clientKey).apply();
|
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)
|
* Get the complete server URL (address + port + path)
|
||||||
*
|
*
|
||||||
@@ -98,9 +110,7 @@ public class PreferencesUtil {
|
|||||||
* @return The complete server URL
|
* @return The complete server URL
|
||||||
*/
|
*/
|
||||||
public static String getServerUrl(Context context) {
|
public static String getServerUrl(Context context) {
|
||||||
String address = getServerAddress(context);
|
return getServerBaseUrl(context) + "/upload";
|
||||||
int port = getServerPort(context);
|
|
||||||
return "http://" + address + ":" + port + "/upload";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -252,13 +252,17 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
List<File> selectedFiles = dumpstateAdapter.getSelectedFiles();
|
List<File> selectedFiles = dumpstateAdapter.getSelectedFiles();
|
||||||
int fileCount = selectedFiles.size();
|
int fileCount = selectedFiles.size();
|
||||||
|
|
||||||
|
// Show upload parameters dialog first
|
||||||
|
new UploadParamsDialog(this, new UploadParamsDialog.UploadParamsListener() {
|
||||||
|
@Override
|
||||||
|
public void onUploadParamsSet(int maxDownloads, int expiryHours) {
|
||||||
// Update UI
|
// Update UI
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
uploadDumpstateButton.setEnabled(false);
|
uploadDumpstateButton.setEnabled(false);
|
||||||
dumpstateStatusText.setText(getString(R.string.preparing_multiple_files, fileCount));
|
dumpstateStatusText.setText(getString(R.string.preparing_multiple_files, fileCount));
|
||||||
|
|
||||||
// Prepare and upload multiple files
|
// Prepare and upload multiple files
|
||||||
FileController.prepareMultipleFilesForUploadAsync(selectedFiles, this)
|
FileController.prepareMultipleFilesForUploadAsync(selectedFiles, MainActivity.this)
|
||||||
.thenAccept(zipFile -> {
|
.thenAccept(zipFile -> {
|
||||||
Log.d(TAG, "Multiple files zipped successfully: " + zipFile.getAbsolutePath());
|
Log.d(TAG, "Multiple files zipped successfully: " + zipFile.getAbsolutePath());
|
||||||
|
|
||||||
@@ -266,8 +270,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
dumpstateStatusText.setText(getString(R.string.uploading_multiple_files, fileCount));
|
dumpstateStatusText.setText(getString(R.string.uploading_multiple_files, fileCount));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload the zip file
|
// Upload the zip file with parameters
|
||||||
startUploadService(zipFile);
|
startUploadService(zipFile, maxDownloads, expiryHours);
|
||||||
})
|
})
|
||||||
.exceptionally(ex -> {
|
.exceptionally(ex -> {
|
||||||
Log.e(TAG, "Error preparing multiple files", ex);
|
Log.e(TAG, "Error preparing multiple files", ex);
|
||||||
@@ -282,6 +286,13 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUploadParamsCancelled() {
|
||||||
|
// User cancelled, do nothing
|
||||||
|
}
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent) {
|
protected void onNewIntent(Intent intent) {
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
@@ -389,13 +400,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI
|
// Show upload parameters dialog
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
showUploadParamsDialog(selectedFile);
|
||||||
uploadSelectedFileButton.setEnabled(false);
|
|
||||||
selectedFileText.setText(getString(R.string.uploading_file, selectedFile.getName()));
|
|
||||||
|
|
||||||
// Start the upload service
|
|
||||||
startUploadService(selectedFile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -413,7 +419,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (dumpstateFiles.length == 1) {
|
if (dumpstateFiles.length == 1) {
|
||||||
// Only one dumpstate file, use it directly
|
// Only one dumpstate file, use it directly
|
||||||
dumpstateFile = dumpstateFiles[0];
|
dumpstateFile = dumpstateFiles[0];
|
||||||
startDumpstateUpload(dumpstateFile);
|
showUploadParamsDialog(dumpstateFile);
|
||||||
} else {
|
} else {
|
||||||
// Multiple dumpstate files are available, suggest expanding the list
|
// Multiple dumpstate files are available, suggest expanding the list
|
||||||
if (!isDumpstateListExpanded) {
|
if (!isDumpstateListExpanded) {
|
||||||
@@ -421,7 +427,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
} else {
|
} else {
|
||||||
// If list is already expanded, use the first file
|
// If list is already expanded, use the first file
|
||||||
dumpstateFile = dumpstateFiles[0];
|
dumpstateFile = dumpstateFiles[0];
|
||||||
startDumpstateUpload(dumpstateFile);
|
showUploadParamsDialog(dumpstateFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,8 +436,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
* Start uploading the selected dumpstate file
|
* Start uploading the selected dumpstate file
|
||||||
*
|
*
|
||||||
* @param file The dumpstate file to upload
|
* @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()) {
|
if (file == null || !file.exists()) {
|
||||||
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
@@ -442,22 +450,57 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uploadDumpstateButton.setEnabled(false);
|
uploadDumpstateButton.setEnabled(false);
|
||||||
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
|
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
|
||||||
|
|
||||||
// Start the upload service
|
// Start the upload service with parameters
|
||||||
startUploadService(file);
|
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 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
|
// Start the upload service
|
||||||
Intent serviceIntent = new Intent(this, FileUploadForegroundService.class);
|
Intent serviceIntent = new Intent(this, FileUploadForegroundService.class);
|
||||||
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, file.getAbsolutePath());
|
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, file.getAbsolutePath());
|
||||||
|
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, maxDownloads);
|
||||||
|
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_EXPIRY_HOURS, expiryHours);
|
||||||
startService(serviceIntent);
|
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
|
* Update the selected file UI
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.view.View;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.lifecycle.Observer;
|
import androidx.lifecycle.Observer;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
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
|
@Override
|
||||||
@@ -83,8 +97,30 @@ public class UploadHistoryActivity extends AppCompatActivity implements UploadHi
|
|||||||
finish();
|
finish();
|
||||||
return true;
|
return true;
|
||||||
} else if (id == R.id.action_clear_history) {
|
} else if (id == R.id.action_clear_history) {
|
||||||
viewModel.deleteAll();
|
// Show confirmation dialog
|
||||||
Toast.makeText(this, "History cleared", Toast.LENGTH_SHORT).show();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +134,29 @@ public class UploadHistoryActivity extends AppCompatActivity implements UploadHi
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDeleteClick(UploadHistory history) {
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import android.app.Application;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.lifecycle.AndroidViewModel;
|
import androidx.lifecycle.AndroidViewModel;
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
|
||||||
|
import com.mattintech.simplelogupload.controller.UploadController;
|
||||||
import com.mattintech.simplelogupload.db.UploadHistoryRepository;
|
import com.mattintech.simplelogupload.db.UploadHistoryRepository;
|
||||||
import com.mattintech.simplelogupload.model.UploadHistory;
|
import com.mattintech.simplelogupload.model.UploadHistory;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ViewModel for upload history
|
* ViewModel for upload history
|
||||||
@@ -17,7 +20,9 @@ import java.util.List;
|
|||||||
public class UploadHistoryViewModel extends AndroidViewModel {
|
public class UploadHistoryViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
private final UploadHistoryRepository repository;
|
private final UploadHistoryRepository repository;
|
||||||
|
private final UploadController uploadController;
|
||||||
private final LiveData<List<UploadHistory>> allHistory;
|
private final LiveData<List<UploadHistory>> allHistory;
|
||||||
|
private final MutableLiveData<Boolean> deleteStatus = new MutableLiveData<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@@ -27,6 +32,7 @@ public class UploadHistoryViewModel extends AndroidViewModel {
|
|||||||
public UploadHistoryViewModel(@NonNull Application application) {
|
public UploadHistoryViewModel(@NonNull Application application) {
|
||||||
super(application);
|
super(application);
|
||||||
repository = new UploadHistoryRepository(application);
|
repository = new UploadHistoryRepository(application);
|
||||||
|
uploadController = new UploadController(application);
|
||||||
allHistory = repository.getAllHistory();
|
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
|
* @param history The entry to delete
|
||||||
|
* @return CompletableFuture for the operation
|
||||||
*/
|
*/
|
||||||
public void delete(UploadHistory 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);
|
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() {
|
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();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
app/src/main/res/layout/dialog_upload_params.xml
Normal file
97
app/src/main/res/layout/dialog_upload_params.xml
Normal 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>
|
||||||
@@ -56,4 +56,15 @@
|
|||||||
<string name="no_dumpstates_selected">No files selected</string>
|
<string name="no_dumpstates_selected">No files selected</string>
|
||||||
<string name="preparing_multiple_files">Preparing %1$d files for upload…</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>
|
<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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user