Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eff3478a97 | |||
| 04b3bea454 | |||
| 41363028aa | |||
| d40814ec4c | |||
| c80d523972 | |||
| e7335977c0 | |||
| 4557d13064 | |||
| 4ac3114064 | |||
| 836c870204 | |||
| 67e26a095c | |||
| 0b3d16f974 | |||
| a79ccf8f00 | |||
| e60cdf9355 | |||
| d9052454fe | |||
| 4a7a48c9a4 | |||
| 2c8315cb5d | |||
| 7b66e8cf68 | |||
| 0872fcdfb2 | |||
| c3fbff222c | |||
| 0e08718566 | |||
| a86c7b74aa | |||
| d32fd4690d | |||
| 76b93e3363 |
66
PROJECT.md
Normal file
66
PROJECT.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# SimpleLogUpload Project Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
SimpleLogUpload is an Android application designed to upload log files to a server. It provides both standard and chunked upload capabilities to handle files of various sizes across different network conditions.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
This project follows specific documentation conventions:
|
||||
|
||||
1. **Main documentation**:
|
||||
- `README.md` - General project information and setup instructions
|
||||
- `PROJECT.md` (this file) - Project structure and documentation guidelines
|
||||
|
||||
2. **Feature documentation**:
|
||||
- Each feature must have its own dedicated Markdown file in the `./docs` folder
|
||||
- Feature documentation should thoroughly explain the implementation, usage, and any special considerations
|
||||
|
||||
3. **Task tracking**:
|
||||
- `TASK.md` - Contains ongoing tasks and development roadmap
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
### Feature Documentation Rule
|
||||
|
||||
**IMPORTANT**: For each new feature added to the project, a corresponding Markdown file must be created in the `./docs` folder. This file should:
|
||||
|
||||
1. Have a clear, descriptive filename (e.g., `FEATURE_NAME.md`)
|
||||
2. Contain comprehensive documentation including:
|
||||
- Feature overview and purpose
|
||||
- Technical implementation details
|
||||
- API descriptions (if applicable)
|
||||
- Usage examples
|
||||
- Configuration options
|
||||
- Testing considerations
|
||||
|
||||
### Current Feature Documentation
|
||||
|
||||
The current feature documentation includes:
|
||||
|
||||
- `docs/CHUNKED_UPLOAD.md` - Details on the chunked upload implementation
|
||||
- `docs/LARGE_UPLOAD.md` - Analysis and improvements for large file uploads
|
||||
- `docs/RELEASE_PROCESS.md` - Guidelines for the release process
|
||||
|
||||
## Maintaining Documentation
|
||||
|
||||
When modifying or extending existing features, ensure the corresponding documentation is updated to reflect the changes. Keep all documentation accurate and up-to-date with the current implementation.
|
||||
|
||||
## Adding New Documentation
|
||||
|
||||
To add documentation for a new feature:
|
||||
|
||||
1. Create a new Markdown file in the `./docs` folder
|
||||
2. Follow the naming convention `FEATURE_NAME.md`
|
||||
3. Use clear sections and subsections for easy navigation
|
||||
4. Include code examples where appropriate
|
||||
5. Add links to the new documentation in other relevant documents
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
All documentation should:
|
||||
- Be clear and concise
|
||||
- Include code examples where applicable
|
||||
- Explain the "why" as well as the "how"
|
||||
- Be kept up-to-date with code changes
|
||||
- Follow a consistent format and style
|
||||
@@ -48,6 +48,15 @@ Note - this requires that you leverage SimpleFileUpload-Server
|
||||
- Upload history filtering and search
|
||||
- QR code generation for quick sharing
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed documentation about specific features and processes can be found in the `docs/` directory:
|
||||
|
||||
- [Project Documentation](PROJECT.md) - Project structure and documentation guidelines
|
||||
- [Chunked Upload](docs/CHUNKED_UPLOAD.md) - Implementation of chunked uploads for large files
|
||||
- [Large File Upload Analysis](docs/LARGE_UPLOAD.md) - Analysis and improvements for large file uploads
|
||||
- [Release Process](docs/RELEASE_PROCESS.md) - Detailed release process guidelines
|
||||
|
||||
## Development and Releases
|
||||
|
||||
### Automated Releases with GitHub Actions
|
||||
|
||||
10
TASK.md
10
TASK.md
@@ -84,3 +84,13 @@
|
||||
- [x] Multi-Dumpstate upload
|
||||
- [ ] Dumpstate cleanup after successful upload
|
||||
- [ ] Provide steps of how to collect a dumpstate and download the server
|
||||
|
||||
## 🔄 Chunked Upload Implementation
|
||||
- [x] Implement basic chunked upload system for large files (>100MB)
|
||||
- [x] Add network type detection and adaptive settings for WiFi vs. Mobile
|
||||
- [x] Implement resume capability for interrupted uploads
|
||||
- [x] Add parallel chunk uploading for WiFi connections
|
||||
- [x] Create network state monitoring to handle connection changes
|
||||
- [x] Implement progressive backoff and retry strategy
|
||||
- [x] Add enhanced progress tracking for chunks
|
||||
- [x] Test on both WiFi and 5G with various file sizes
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.mattintech.simplelogupload"
|
||||
minSdkVersion 30
|
||||
targetSdkVersion 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 2
|
||||
versionName "25.12.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ android {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.5.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -56,6 +64,9 @@ dependencies {
|
||||
implementation 'com.android.support:support-annotations:28.0.0'
|
||||
implementation 'androidx.palette:palette:1.0.0'
|
||||
|
||||
// QR Code scanning
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// Room
|
||||
def room_version = "2.6.1"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
@@ -74,6 +85,23 @@ dependencies {
|
||||
// Coroutines
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
|
||||
// Jetpack Compose
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.activity:activity-compose:1.9.3'
|
||||
implementation 'androidx.navigation:navigation-compose:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata'
|
||||
|
||||
// Compose Debug
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
|
||||
// Testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.mattintech.simplelogupload">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_app_icon"
|
||||
android:roundIcon="@drawable/ic_app_icon"
|
||||
android:label="SimpleLogUpload"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
@@ -20,10 +24,13 @@
|
||||
android:usesCleartextTraffic="false"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<!-- NEW: Compose Main Activity (LAUNCHER) -->
|
||||
<activity
|
||||
android:name=".view.MainActivity"
|
||||
android:exported="true">
|
||||
android:name=".ComposeMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
|
||||
<!-- LAUNCHER - App starts here -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -44,44 +51,14 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Upload History Activity -->
|
||||
<!-- QR Code Scanner Activity -->
|
||||
<activity
|
||||
android:name=".view.UploadHistoryActivity"
|
||||
android:label="Upload History"
|
||||
android:parentActivityName=".view.MainActivity"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<!-- Settings Activity -->
|
||||
<activity
|
||||
android:name=".view.SettingsActivity"
|
||||
android:label="Settings"
|
||||
android:parentActivityName=".view.MainActivity"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<!-- Welcome Activity -->
|
||||
<activity
|
||||
android:name=".view.WelcomeActivity"
|
||||
android:label="Welcome"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
|
||||
|
||||
<!-- Server Setup Activity -->
|
||||
<activity
|
||||
android:name=".view.ServerSetupActivity"
|
||||
android:label="Server Setup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.WelcomeActivity" />
|
||||
</activity>
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<!-- File Upload Foreground Service -->
|
||||
<service
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.mattintech.simplelogupload
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.mattintech.simplelogupload.navigation.AppNavigation
|
||||
import com.mattintech.simplelogupload.navigation.Screen
|
||||
import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
|
||||
/**
|
||||
* Main Activity for the Compose-based UI
|
||||
* Replaces all previous XML-based Activities with a single Compose Activity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Enable edge-to-edge display for modern UI
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Determine start destination based on setup status
|
||||
val startDestination = getStartDestination()
|
||||
|
||||
// Handle share intents if this was launched from external app
|
||||
val sharedFileUri = handleShareIntent(intent)
|
||||
|
||||
setContent {
|
||||
SimpleLogUploadTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle new share intents when activity is already running
|
||||
handleShareIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which screen to show first based on app configuration
|
||||
*/
|
||||
private fun getStartDestination(): String {
|
||||
// Check if onboarding has been completed first
|
||||
if (!PreferencesUtil.isOnboardingCompleted(this)) {
|
||||
return Screen.Onboarding.route
|
||||
}
|
||||
|
||||
// Then check if setup is complete
|
||||
return if (PreferencesUtil.isSetupCompleted(this) &&
|
||||
PreferencesUtil.areServerSettingsConfigured(this)) {
|
||||
Screen.Main.route
|
||||
} else {
|
||||
Screen.Welcome.route
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle share intents from external apps
|
||||
*
|
||||
* @param intent The intent that launched this activity
|
||||
* @return Uri of shared file, or null if not a share intent
|
||||
*/
|
||||
private fun handleShareIntent(intent: Intent?): Uri? {
|
||||
if (intent?.action == Intent.ACTION_SEND) {
|
||||
if (intent.type?.startsWith("text/") == true) {
|
||||
// Handle shared text
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
// TODO: Handle shared text if needed
|
||||
return null
|
||||
} else {
|
||||
// Handle shared file
|
||||
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Pass this URI to MainScreen/MainViewModel for upload
|
||||
return sharedUri
|
||||
}
|
||||
} else if (intent?.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
// Handle multiple shared files
|
||||
val sharedUris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Handle multiple files
|
||||
return sharedUris?.firstOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package com.mattintech.simplelogupload.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.format.Formatter;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Adapter for displaying dumpstate files in a RecyclerView with multi-selection capability
|
||||
*/
|
||||
public class DumpstateAdapter extends RecyclerView.Adapter<DumpstateAdapter.ViewHolder> {
|
||||
|
||||
private final List<File> files;
|
||||
private final Context context;
|
||||
private final Set<Integer> selectedPositions = new HashSet<>();
|
||||
private OnSelectionChangedListener selectionChangedListener;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param files List of dumpstate files
|
||||
*/
|
||||
public DumpstateAdapter(Context context, List<File> files) {
|
||||
this.context = context;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for selection changes
|
||||
*
|
||||
* @param listener The listener
|
||||
*/
|
||||
public void setOnSelectionChangedListener(OnSelectionChangedListener listener) {
|
||||
this.selectionChangedListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of selected files
|
||||
*
|
||||
* @return List of selected files
|
||||
*/
|
||||
public List<File> getSelectedFiles() {
|
||||
List<File> selectedFiles = new ArrayList<>();
|
||||
for (int position : selectedPositions) {
|
||||
if (position >= 0 && position < files.size()) {
|
||||
selectedFiles.add(files.get(position));
|
||||
}
|
||||
}
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item is selected
|
||||
*
|
||||
* @param position The position to check
|
||||
* @return true if selected, false otherwise
|
||||
*/
|
||||
public boolean isSelected(int position) {
|
||||
return selectedPositions.contains(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select or deselect an item
|
||||
*
|
||||
* @param position The position to select/deselect
|
||||
* @param selected Whether to select (true) or deselect (false)
|
||||
*/
|
||||
public void setSelected(int position, boolean selected) {
|
||||
if (selected) {
|
||||
selectedPositions.add(position);
|
||||
} else {
|
||||
selectedPositions.remove(position);
|
||||
}
|
||||
notifyItemChanged(position);
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(selectedPositions.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle selection state of an item
|
||||
*
|
||||
* @param position The position to toggle
|
||||
*/
|
||||
public void toggleSelection(int position) {
|
||||
setSelected(position, !isSelected(position));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all items
|
||||
*/
|
||||
public void selectAll() {
|
||||
selectedPositions.clear();
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
selectedPositions.add(i);
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(selectedPositions.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
public void clearSelections() {
|
||||
selectedPositions.clear();
|
||||
notifyDataSetChanged();
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of selected items
|
||||
*
|
||||
* @return The count of selected items
|
||||
*/
|
||||
public int getSelectedCount() {
|
||||
return selectedPositions.size();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_dumpstate_file, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
File file = files.get(position);
|
||||
|
||||
// Set file name
|
||||
holder.fileNameText.setText(file.getName());
|
||||
|
||||
// Add tooltip for long filenames
|
||||
holder.fileNameText.setOnLongClickListener(v -> {
|
||||
Toast.makeText(context, file.getName(), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Format and set file date
|
||||
Date lastModified = new Date(file.lastModified());
|
||||
String formattedDate = DateFormat.format("MMM dd, yyyy hh:mm a", lastModified).toString();
|
||||
holder.fileDateText.setText(context.getString(R.string.dumpstate_date_format, formattedDate));
|
||||
|
||||
// Format and set file size
|
||||
String fileSize = Formatter.formatFileSize(context, file.length());
|
||||
holder.fileSizeText.setText(context.getString(R.string.dumpstate_size_format, fileSize));
|
||||
|
||||
// Set checkbox state
|
||||
holder.checkBox.setChecked(isSelected(position));
|
||||
|
||||
// Set click listeners
|
||||
holder.checkBox.setOnClickListener(v -> toggleSelection(holder.getAbsoluteAdapterPosition()));
|
||||
|
||||
holder.itemView.setOnClickListener(v -> toggleSelection(holder.getAbsoluteAdapterPosition()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for dumpstate file items
|
||||
*/
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
final CheckBox checkBox;
|
||||
final TextView fileNameText;
|
||||
final TextView fileDateText;
|
||||
final TextView fileSizeText;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
checkBox = itemView.findViewById(R.id.checkBox);
|
||||
fileNameText = itemView.findViewById(R.id.fileNameText);
|
||||
fileDateText = itemView.findViewById(R.id.fileDateText);
|
||||
fileSizeText = itemView.findViewById(R.id.fileSizeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handling selection changes
|
||||
*/
|
||||
public interface OnSelectionChangedListener {
|
||||
void onSelectionChanged(int selectedCount);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.mattintech.simplelogupload.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.format.Formatter;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Adapter for displaying dumpstate files in a RecyclerView
|
||||
*/
|
||||
public class DumpstateFileAdapter extends RecyclerView.Adapter<DumpstateFileAdapter.ViewHolder> {
|
||||
|
||||
private final List<File> files;
|
||||
private final Context context;
|
||||
private int selectedPosition = -1;
|
||||
private OnItemSelectedListener listener;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param files List of dumpstate files
|
||||
*/
|
||||
public DumpstateFileAdapter(Context context, List<File> files) {
|
||||
this.context = context;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for item selection
|
||||
*
|
||||
* @param listener The listener
|
||||
*/
|
||||
public void setOnItemSelectedListener(OnItemSelectedListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected file
|
||||
*
|
||||
* @return The selected file or null if none selected
|
||||
*/
|
||||
public File getSelectedFile() {
|
||||
if (selectedPosition >= 0 && selectedPosition < files.size()) {
|
||||
return files.get(selectedPosition);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_dumpstate_file, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
File file = files.get(position);
|
||||
|
||||
// Set file name
|
||||
holder.fileNameText.setText(file.getName());
|
||||
|
||||
// Add tooltip for long filenames
|
||||
holder.fileNameText.setOnLongClickListener(v -> {
|
||||
Toast.makeText(context, file.getName(), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Format and set file date
|
||||
Date lastModified = new Date(file.lastModified());
|
||||
String formattedDate = DateFormat.format("MMM dd, yyyy hh:mm a", lastModified).toString();
|
||||
holder.fileDateText.setText(context.getString(R.string.dumpstate_date_format, formattedDate));
|
||||
|
||||
// Format and set file size
|
||||
String fileSize = Formatter.formatFileSize(context, file.length());
|
||||
holder.fileSizeText.setText(context.getString(R.string.dumpstate_size_format, fileSize));
|
||||
|
||||
// Set checkbox state
|
||||
holder.checkBox.setChecked(position == selectedPosition);
|
||||
|
||||
// Set click listener for the whole item
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
int previousSelected = selectedPosition;
|
||||
selectedPosition = holder.getAdapterPosition();
|
||||
|
||||
// Update previous selected item
|
||||
if (previousSelected >= 0) {
|
||||
notifyItemChanged(previousSelected);
|
||||
}
|
||||
|
||||
// Update newly selected item
|
||||
notifyItemChanged(selectedPosition);
|
||||
|
||||
// Notify listener
|
||||
if (listener != null) {
|
||||
listener.onItemSelected(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for dumpstate file items
|
||||
*/
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
final CheckBox checkBox;
|
||||
final TextView fileNameText;
|
||||
final TextView fileDateText;
|
||||
final TextView fileSizeText;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
checkBox = itemView.findViewById(R.id.checkBox);
|
||||
fileNameText = itemView.findViewById(R.id.fileNameText);
|
||||
fileDateText = itemView.findViewById(R.id.fileDateText);
|
||||
fileSizeText = itemView.findViewById(R.id.fileSizeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handling item selection
|
||||
*/
|
||||
public interface OnItemSelectedListener {
|
||||
void onItemSelected(File file);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,26 @@ package com.mattintech.simplelogupload.constant;
|
||||
public class AppConstants {
|
||||
public static final String APP_TAG = "SLU::";
|
||||
|
||||
// Broadcast actions
|
||||
// Broadcast actions (service -> UI)
|
||||
public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
|
||||
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR";
|
||||
public static final String ACTION_UPLOAD_PROGRESS = "com.mattintech.simplelogupload.UPLOAD_PROGRESS";
|
||||
public static final String ACTION_ZIP_PROGRESS = "com.mattintech.simplelogupload.ZIP_PROGRESS";
|
||||
public static final String ACTION_UPLOAD_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED";
|
||||
public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED";
|
||||
public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED";
|
||||
|
||||
// Intent actions (UI/notification -> service)
|
||||
public static final String ACTION_PAUSE_UPLOAD = "com.mattintech.simplelogupload.PAUSE_UPLOAD";
|
||||
public static final String ACTION_RESUME_UPLOAD = "com.mattintech.simplelogupload.RESUME_UPLOAD";
|
||||
public static final String ACTION_CANCEL_UPLOAD = "com.mattintech.simplelogupload.CANCEL_UPLOAD";
|
||||
|
||||
// Intent extras
|
||||
public static final String EXTRA_UPLOAD_CODE = "upload_code";
|
||||
public static final String EXTRA_UPLOAD_ERROR = "upload_error";
|
||||
public static final String EXTRA_BYTES_UPLOADED = "bytes_uploaded";
|
||||
public static final String EXTRA_TOTAL_BYTES = "total_bytes";
|
||||
public static final String EXTRA_PERCENT_COMPLETE = "percent_complete";
|
||||
|
||||
// File patterns
|
||||
public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip";
|
||||
|
||||
@@ -8,11 +8,25 @@ public class NetworkConstants {
|
||||
// via PreferencesUtil to allow for customization
|
||||
public static final String SERVER_URL_BASE = "/upload";
|
||||
|
||||
// Chunked upload endpoints (relative to server base URL)
|
||||
public static final String CHUNKED_UPLOAD_START = "/upload/start";
|
||||
public static final String CHUNKED_UPLOAD_CHUNK = "/upload/chunk";
|
||||
public static final String CHUNKED_UPLOAD_COMPLETE = "/upload/complete";
|
||||
|
||||
// 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";
|
||||
|
||||
// Chunked upload parameters
|
||||
public static final String PARAM_UPLOAD_ID = "upload_id";
|
||||
public static final String PARAM_CHUNK_INDEX = "chunk_index";
|
||||
public static final String PARAM_TOTAL_CHUNKS = "total_chunks";
|
||||
public static final String PARAM_CHUNK = "chunk";
|
||||
public static final String PARAM_FILENAME = "filename";
|
||||
public static final String PARAM_FILESIZE = "filesize";
|
||||
public static final String PARAM_CHUNK_SIZE = "chunk_size";
|
||||
|
||||
// Authentication
|
||||
public static final String CLIENT_KEY_HEADER = "X-Client-Key";
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
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.List;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all dumpstate files
|
||||
*
|
||||
* @return Array of dumpstate files
|
||||
*/
|
||||
public static File[] getAllDumpStateFiles() {
|
||||
return FileUtil.findAllDumpStateFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 multiple files for upload by zipping them into a single file
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
public static void prepareMultipleFilesForUpload(List<File> files, Context context, FilePrepareCallback callback) {
|
||||
if (files == null || files.isEmpty()) {
|
||||
callback.onError(new IOException("No files provided"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION;
|
||||
|
||||
try {
|
||||
// Use cache directory for temporary zip storage
|
||||
ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, new ZipUtil.ZipProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesProcessed, long totalBytes) {
|
||||
int progress = (int) ((bytesProcessed * 100) / Math.max(totalBytes, 1)); // Avoid division by zero
|
||||
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 multiple files 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload asynchronously
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
public static CompletableFuture<File> prepareMultipleFilesForUploadAsync(List<File> files, Context context) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IOException("No files provided");
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION;
|
||||
|
||||
// Use cache directory for temporary zip storage
|
||||
return ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, null);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error preparing multiple files 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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 kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Controller for file operations using Kotlin coroutines
|
||||
*/
|
||||
object FileController {
|
||||
private const val TAG = AppConstants.APP_TAG + "FileController"
|
||||
|
||||
// Track current zip job for cancellation
|
||||
private var currentZipJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Interface for file preparation callbacks
|
||||
*/
|
||||
interface FilePrepareCallback {
|
||||
fun onSuccess(preparedFile: File)
|
||||
fun onProgress(progress: Int)
|
||||
fun onError(e: Exception)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent dumpstate file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getMostRecentDumpStateFile(): File? {
|
||||
return FileUtil.findMostRecentDumpStateFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all dumpstate files
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getAllDumpStateFiles(): Array<File> {
|
||||
return FileUtil.findAllDumpStateFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload - ensures the file is zipped if needed
|
||||
* Runs zipping on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareFileForUpload(file: File, callback: FilePrepareCallback) {
|
||||
if (!file.exists()) {
|
||||
callback.onError(IOException("File does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
callback.onError(
|
||||
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 on a background thread
|
||||
currentZipJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
val progress = ((bytesProcessed * 100) / totalBytes).toInt()
|
||||
Log.d(TAG, "Zip progress callback: $progress%")
|
||||
// Post callback to main thread for UI updates
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
Log.d(TAG, "Zip complete callback")
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onSuccess(zipFile)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
Log.e(TAG, "Zip error callback", e)
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: CancellationException) {
|
||||
// Cancellation is expected, just clean up
|
||||
Log.d(TAG, "File preparation cancelled")
|
||||
currentZipJob = null
|
||||
// Don't call error callback for cancellation
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing file for upload", e)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload by zipping them into a single file
|
||||
* Runs zipping on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareMultipleFilesForUpload(
|
||||
files: List<File>,
|
||||
context: Context,
|
||||
callback: FilePrepareCallback
|
||||
) {
|
||||
if (files.isEmpty()) {
|
||||
callback.onError(IOException("No files provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}"
|
||||
|
||||
// Zip files on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFiles(
|
||||
files,
|
||||
context.cacheDir,
|
||||
zipFileName,
|
||||
object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
val progress = if (totalBytes > 0) {
|
||||
((bytesProcessed * 100) / totalBytes).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Post callback to main thread for UI updates
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onSuccess(zipFile)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun saveContentUriToFile(context: Context, uri: Uri, filename: String): File {
|
||||
// Create a temporary file
|
||||
val outputFile = File(context.cacheDir, filename)
|
||||
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(outputFile).use { outputStream ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Could not open input stream for URI: $uri")
|
||||
|
||||
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
|
||||
*/
|
||||
@JvmStatic
|
||||
fun isValidFileSize(file: File): Boolean {
|
||||
return FileUtil.isValidFileSize(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing file preparation (zipping) operation
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancelPreparation() {
|
||||
currentZipJob?.cancel()
|
||||
currentZipJob = null
|
||||
Log.d(TAG, "Cancelled file preparation")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareFileForUploadAsync(file: File): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
if (!file.exists()) {
|
||||
future.completeExceptionally(IOException("File does not exist"))
|
||||
return future
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
future.completeExceptionally(
|
||||
IOException("File size exceeds limit of ${FileConstants.MAX_FILE_SIZE} bytes")
|
||||
)
|
||||
return future
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(file)) {
|
||||
future.complete(file)
|
||||
return future
|
||||
}
|
||||
|
||||
// Otherwise, compress it on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
var resultFile: File? = null
|
||||
ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
// Progress not needed for async API
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
resultFile = zipFile
|
||||
future.complete(zipFile)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing file for upload", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareMultipleFilesForUploadAsync(
|
||||
files: List<File>,
|
||||
context: Context
|
||||
): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
if (files.isEmpty()) {
|
||||
future.completeExceptionally(IOException("No files provided"))
|
||||
return future
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}"
|
||||
|
||||
// Zip files on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFiles(
|
||||
files,
|
||||
context.cacheDir,
|
||||
zipFileName,
|
||||
object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
// Progress not needed for async API
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
future.complete(zipFile)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a content URI to a temporary file asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param context The context
|
||||
* @param uri The content URI
|
||||
* @param filename The desired filename
|
||||
* @return CompletableFuture containing the saved file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun saveContentUriToFileAsync(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
filename: String
|
||||
): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val file = saveContentUriToFile(context, uri, filename)
|
||||
future.complete(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving content URI to file", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
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 com.mattintech.simplelogupload.util.PreferencesUtil;
|
||||
|
||||
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();
|
||||
|
||||
// Get server URL and client key from preferences
|
||||
String serverUrl = PreferencesUtil.getServerUrl(context);
|
||||
String clientKey = PreferencesUtil.getClientKey(context);
|
||||
|
||||
Log.d(TAG, "Using server URL: " + serverUrl);
|
||||
Log.d(TAG, "Adding header '" + NetworkConstants.CLIENT_KEY_HEADER + "' with value: " + clientKey);
|
||||
|
||||
// Build request with client-key header
|
||||
Request request = new Request.Builder()
|
||||
.url(serverUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build();
|
||||
|
||||
Log.d(TAG, "Sending upload request with " + NetworkConstants.CLIENT_KEY_HEADER + " header");
|
||||
|
||||
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;
|
||||
|
||||
// Check for authentication errors
|
||||
if (response.code() == 401 || response.code() == 403) {
|
||||
errorMsg = "Authentication failed. Please check client key.";
|
||||
Log.e(TAG, "Authentication error: " + response.code() + ", Body: " + errorBody);
|
||||
Log.e(TAG, "Header sent: " + NetworkConstants.CLIENT_KEY_HEADER + ": " + clientKey);
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.util.PreferencesUtil
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Controller for handling file uploads with progress tracking
|
||||
*/
|
||||
class UploadController(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = AppConstants.APP_TAG + "UploadController"
|
||||
}
|
||||
|
||||
private val context: Context = context.applicationContext
|
||||
private val repository: UploadHistoryRepository = UploadHistoryRepository(this.context as Application)
|
||||
|
||||
/**
|
||||
* Start an upload using the foreground service
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param maxDownloads Maximum number of downloads
|
||||
* @param expiryHours Expiry time in hours
|
||||
*/
|
||||
fun startUploadService(file: File, maxDownloads: Int, expiryHours: Int) {
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "Cannot start upload service with non-existent file")
|
||||
return
|
||||
}
|
||||
|
||||
val serviceIntent = Intent(context, com.mattintech.simplelogupload.service.FileUploadForegroundService::class.java).apply {
|
||||
putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_FILE_PATH, file.absolutePath)
|
||||
putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, maxDownloads)
|
||||
putExtra(com.mattintech.simplelogupload.service.FileUploadForegroundService.EXTRA_EXPIRY_HOURS, expiryHours)
|
||||
}
|
||||
|
||||
// On Android 12+ (API 31+), we need to use startForegroundService
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
Log.d(TAG, "Starting foreground service (Android 12+)")
|
||||
context.startForegroundService(serviceIntent)
|
||||
} else {
|
||||
Log.d(TAG, "Starting service (pre-Android 12)")
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from the server using its upload code
|
||||
*/
|
||||
fun deleteFileFromServer(uploadCode: String): CompletableFuture<Boolean> {
|
||||
val future = CompletableFuture<Boolean>()
|
||||
|
||||
if (uploadCode.isEmpty()) {
|
||||
Log.e(TAG, "Cannot delete file with empty upload code")
|
||||
future.complete(false)
|
||||
return future
|
||||
}
|
||||
|
||||
// Create OkHttpClient with timeout configuration
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(NetworkConstants.CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.readTimeout(NetworkConstants.READ_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
// Get server base URL and client key from preferences
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
|
||||
// Create the delete URL
|
||||
val deleteUrl = "$serverBaseUrl/delete/$uploadCode"
|
||||
Log.d(TAG, "Deleting file from server: $deleteUrl")
|
||||
|
||||
// Build request with client-key header
|
||||
val request = Request.Builder()
|
||||
.url(deleteUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.delete()
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Server delete failed", e)
|
||||
future.complete(false)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Server delete successful for code: $uploadCode")
|
||||
future.complete(true)
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Server delete failed with code: ${response.code}, Body: $errorBody")
|
||||
future.complete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return future
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.mattintech.simplelogupload.model;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* Model class for QR code server configuration
|
||||
*/
|
||||
public class QrServerConfig {
|
||||
private final String server;
|
||||
private final int port;
|
||||
private final String key;
|
||||
|
||||
private QrServerConfig(String server, int port, String key) {
|
||||
this.server = server;
|
||||
this.port = port;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public String getServer() {
|
||||
return server;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse QR code JSON data
|
||||
*
|
||||
* @param qrData The QR code string data
|
||||
* @return QrServerConfig object
|
||||
* @throws QrConfigException if parsing fails or data is invalid
|
||||
*/
|
||||
public static QrServerConfig fromQrData(String qrData) throws QrConfigException {
|
||||
if (qrData == null || qrData.trim().isEmpty()) {
|
||||
throw new QrConfigException("QR code data is empty");
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject json = new JSONObject(qrData);
|
||||
|
||||
// Extract fields
|
||||
if (!json.has("server")) {
|
||||
throw new QrConfigException("Missing 'server' field in QR code");
|
||||
}
|
||||
if (!json.has("port")) {
|
||||
throw new QrConfigException("Missing 'port' field in QR code");
|
||||
}
|
||||
if (!json.has("key")) {
|
||||
throw new QrConfigException("Missing 'key' field in QR code");
|
||||
}
|
||||
|
||||
String server = json.getString("server");
|
||||
int port = json.getInt("port");
|
||||
String key = json.getString("key");
|
||||
|
||||
// Validate
|
||||
if (server.trim().isEmpty()) {
|
||||
throw new QrConfigException("Server address cannot be empty");
|
||||
}
|
||||
if (port <= 0 || port > 65535) {
|
||||
throw new QrConfigException("Port must be between 1 and 65535");
|
||||
}
|
||||
if (key.trim().isEmpty()) {
|
||||
throw new QrConfigException("Client key cannot be empty");
|
||||
}
|
||||
|
||||
return new QrServerConfig(server.trim(), port, key.trim());
|
||||
|
||||
} catch (JSONException e) {
|
||||
throw new QrConfigException("Invalid QR code format: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom exception for QR configuration errors
|
||||
*/
|
||||
public static class QrConfigException extends Exception {
|
||||
public QrConfigException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.mattintech.simplelogupload.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Entity class for upload history
|
||||
*/
|
||||
@Entity(tableName = "upload_history")
|
||||
data class UploadHistory(
|
||||
@ColumnInfo(name = "file_name")
|
||||
val fileName: String,
|
||||
|
||||
@ColumnInfo(name = "upload_time")
|
||||
val uploadTime: Long,
|
||||
|
||||
@ColumnInfo(name = "upload_code")
|
||||
val uploadCode: String,
|
||||
|
||||
@ColumnInfo(name = "max_downloads")
|
||||
val maxDownloads: Int,
|
||||
|
||||
@ColumnInfo(name = "expiry_hours")
|
||||
val expiryHours: Int
|
||||
) {
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.mattintech.simplelogupload.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.mattintech.simplelogupload.ui.screens.OnboardingScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.WelcomeScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.ServerSetupScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.MainScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.UploadHistoryScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.SettingsScreen
|
||||
|
||||
/**
|
||||
* Main navigation graph for the app
|
||||
*
|
||||
* @param navController Navigation controller
|
||||
* @param startDestination Initial screen to show
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
startDestination: String
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
// Onboarding carousel - shown on very first launch with permissions and server setup
|
||||
composable(Screen.Onboarding.route) {
|
||||
OnboardingScreen(
|
||||
onOnboardingComplete = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
// Clear backstack so user can't go back to onboarding
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Welcome screen - shown on first launch
|
||||
composable(Screen.Welcome.route) {
|
||||
WelcomeScreen(
|
||||
onNavigateToSetup = {
|
||||
navController.navigate(Screen.ServerSetup.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Server setup screen - configure server connection
|
||||
composable(Screen.ServerSetup.route) {
|
||||
ServerSetupScreen(
|
||||
onSetupComplete = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
// Clear backstack so user can't go back to setup
|
||||
popUpTo(Screen.Welcome.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main screen - primary upload functionality
|
||||
composable(Screen.Main.route) {
|
||||
MainScreen(
|
||||
onNavigateToHistory = {
|
||||
navController.navigate(Screen.UploadHistory.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload history screen
|
||||
composable(Screen.UploadHistory.route) {
|
||||
UploadHistoryScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Settings screen
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mattintech.simplelogupload.navigation
|
||||
|
||||
/**
|
||||
* Sealed class representing all navigation destinations in the app
|
||||
*/
|
||||
sealed class Screen(val route: String) {
|
||||
/**
|
||||
* Onboarding carousel with app intro and permissions
|
||||
*/
|
||||
data object Onboarding : Screen("onboarding")
|
||||
|
||||
/**
|
||||
* Welcome/onboarding screen shown on first launch
|
||||
*/
|
||||
data object Welcome : Screen("welcome")
|
||||
|
||||
/**
|
||||
* Server setup screen for configuring server connection
|
||||
*/
|
||||
data object ServerSetup : Screen("server_setup")
|
||||
|
||||
/**
|
||||
* Main screen with upload functionality and bottom navigation
|
||||
*/
|
||||
data object Main : Screen("main")
|
||||
|
||||
/**
|
||||
* Upload history screen showing past uploads
|
||||
*/
|
||||
data object UploadHistory : Screen("upload_history")
|
||||
|
||||
/**
|
||||
* Settings screen for app configuration
|
||||
*/
|
||||
data object Settings : Screen("settings")
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
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);
|
||||
|
||||
// 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
|
||||
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());
|
||||
}
|
||||
|
||||
// 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, finalMaxDownloads, finalExpiryHours);
|
||||
}
|
||||
|
||||
@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.ic_notification)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
package com.mattintech.simplelogupload.service
|
||||
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.app.Service.STOP_FOREGROUND_DETACH
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.mattintech.simplelogupload.ComposeMainActivity
|
||||
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.db.UploadHistoryRepository
|
||||
import com.mattintech.simplelogupload.model.UploadHistory
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import com.mattintech.simplelogupload.upload.ChunkedUploadManager
|
||||
import com.mattintech.simplelogupload.upload.UploadProgressCallback
|
||||
import com.mattintech.simplelogupload.upload.UploadStrategySelector
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Foreground service for file uploads with progress tracking
|
||||
*/
|
||||
class FileUploadForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = AppConstants.APP_TAG + "UploadService"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
// Intent extras
|
||||
const val EXTRA_FILE_PATH = "extra_file_path"
|
||||
const val EXTRA_MAX_DOWNLOADS = "extra_max_downloads"
|
||||
const val EXTRA_EXPIRY_HOURS = "extra_expiry_hours"
|
||||
}
|
||||
|
||||
private lateinit var uploadController: UploadController
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var repository: UploadHistoryRepository
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private var lastProgressUpdate: Long = 0
|
||||
|
||||
// Upload state tracking
|
||||
private var currentUploadManager: ChunkedUploadManager? = null
|
||||
private var currentStandardUploadCall: Call? = null
|
||||
private var currentZipJob: kotlinx.coroutines.Job? = null
|
||||
private var uploadState: UploadState = UploadState.IDLE
|
||||
|
||||
enum class UploadState {
|
||||
IDLE, PREPARING, UPLOADING, PAUSED, CANCELLED
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
uploadController = UploadController(this)
|
||||
repository = UploadHistoryRepository(application as Application)
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "========== Foreground Service started ==========")
|
||||
|
||||
if (intent == null) {
|
||||
Log.e(TAG, "Intent is null")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Handle control actions
|
||||
when (intent.action) {
|
||||
AppConstants.ACTION_PAUSE_UPLOAD -> {
|
||||
handlePauseUpload()
|
||||
return START_STICKY
|
||||
}
|
||||
AppConstants.ACTION_RESUME_UPLOAD -> {
|
||||
handleResumeUpload()
|
||||
return START_STICKY
|
||||
}
|
||||
AppConstants.ACTION_CANCEL_UPLOAD -> {
|
||||
handleCancelUpload()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Intent received, extracting parameters...")
|
||||
|
||||
// Get file path from intent
|
||||
var filePath = intent.getStringExtra(EXTRA_FILE_PATH)
|
||||
val maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS)
|
||||
var 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
|
||||
val mostRecentFile = FileController.getMostRecentDumpStateFile()
|
||||
if (mostRecentFile != null) {
|
||||
filePath = mostRecentFile.absolutePath
|
||||
} else {
|
||||
Log.e(TAG, "No file to upload")
|
||||
sendErrorBroadcast("No file to upload")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
val file = 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
|
||||
Log.d(TAG, "Creating initial notification and starting foreground...")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// For Android 14+ (API 34+)
|
||||
try {
|
||||
Log.d(TAG, "Starting foreground service with DATA_SYNC type (Android 14+)")
|
||||
val dataSync = 1 // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC value is 1
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification(), dataSync)
|
||||
Log.d(TAG, "Foreground service started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting foreground service with type", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
} else {
|
||||
// For Android 13 and below
|
||||
Log.d(TAG, "Starting foreground service (Android <14)")
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification())
|
||||
Log.d(TAG, "Foreground service started successfully")
|
||||
}
|
||||
|
||||
// Mark upload as in progress
|
||||
PreferencesUtil.setUploadInProgress(this, true, file.name)
|
||||
|
||||
// Prepare file for upload (zip if needed based on extension and file type)
|
||||
Log.d(TAG, "Starting file preparation...")
|
||||
|
||||
// Check if file needs zipping to show appropriate message
|
||||
val needsZipping = !com.mattintech.simplelogupload.util.FileUtil.isZipFile(file)
|
||||
|
||||
// Set state to PREPARING
|
||||
uploadState = UploadState.PREPARING
|
||||
|
||||
FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback {
|
||||
override fun onSuccess(preparedFile: File) {
|
||||
if (uploadState == UploadState.CANCELLED) {
|
||||
Log.d(TAG, "Upload cancelled during preparation")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "File prepared for upload: ${preparedFile.name}")
|
||||
Log.d(TAG, "Starting upload process...")
|
||||
uploadState = UploadState.IDLE
|
||||
uploadFile(preparedFile, maxDownloads, expiryHours)
|
||||
}
|
||||
|
||||
override fun onProgress(progress: Int) {
|
||||
if (uploadState == UploadState.CANCELLED) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle notification updates to every 500ms to ensure visibility
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val shouldUpdate = currentTime - lastProgressUpdate >= 500 || progress == 100
|
||||
|
||||
Log.d(TAG, "File preparation progress: $progress% (shouldUpdate: $shouldUpdate)")
|
||||
|
||||
if (shouldUpdate) {
|
||||
lastProgressUpdate = currentTime
|
||||
val message = if (needsZipping) {
|
||||
"Compressing file: $progress%"
|
||||
} else {
|
||||
"Preparing file: $progress%"
|
||||
}
|
||||
updateNotification(message, progress, 100)
|
||||
}
|
||||
|
||||
// Always send broadcast for in-app UI (not throttled)
|
||||
sendZipProgressBroadcast(progress)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
Log.e(TAG, "Error preparing file", e)
|
||||
PreferencesUtil.setUploadInProgress(this@FileUploadForegroundService, false, null)
|
||||
sendErrorBroadcast("Error preparing file: ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
})
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* Upload a file directly with progress tracking
|
||||
*/
|
||||
private fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Determine upload strategy
|
||||
val useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(this, file)
|
||||
val networkType = UploadStrategySelector.getNetworkTypeName(this)
|
||||
|
||||
Log.d(TAG, "Starting upload - Network: $networkType, Chunked: $useChunkedUpload, File: ${file.name}")
|
||||
|
||||
// Execute chosen strategy
|
||||
val uploadFuture = if (useChunkedUpload) {
|
||||
performChunkedUpload(file, maxDownloads, expiryHours)
|
||||
} else {
|
||||
performStandardUpload(file, maxDownloads, expiryHours)
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
uploadFuture.thenAccept { result ->
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "Upload successful! Code: ${result.code}")
|
||||
updateNotificationComplete("Upload complete! Code: ${result.code}", result.code)
|
||||
sendSuccessBroadcast(result.code)
|
||||
} else {
|
||||
Log.e(TAG, "Upload failed: ${result.error}")
|
||||
updateNotification("Upload failed: ${result.error}", -1, 100, isComplete = true)
|
||||
sendErrorBroadcast(result.error)
|
||||
}
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Detach notification from foreground service so it persists after service stops
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false) // Keep notification visible
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
}.exceptionally { ex ->
|
||||
Log.e(TAG, "Exception during upload", ex)
|
||||
updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true)
|
||||
sendErrorBroadcast("Error during upload: ${ex.message}")
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Stop service after a short delay
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false)
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a chunked upload for large files
|
||||
*/
|
||||
private fun performChunkedUpload(
|
||||
file: File,
|
||||
maxDownloads: Int,
|
||||
expiryHours: Int
|
||||
): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
// CRITICAL: Show session initialization progress IMMEDIATELY
|
||||
updateNotification("Initializing upload session...", -1, 100)
|
||||
|
||||
// Create chunked upload manager directly
|
||||
val uploadManager = ChunkedUploadManager(this, file, maxDownloads, expiryHours)
|
||||
|
||||
// Store reference for pause/resume/cancel control
|
||||
currentUploadManager = uploadManager
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
// Set progress listener with direct notification updates
|
||||
uploadManager.setProgressListener(object : ChunkedUploadManager.UploadProgressListener {
|
||||
override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) {
|
||||
// Direct notification update - no intermediate callbacks
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastProgressUpdate >= 500 || percentComplete == 100) {
|
||||
lastProgressUpdate = currentTime
|
||||
|
||||
val uploadedMB = bytesUploaded / (1024f * 1024f)
|
||||
val totalMB = totalBytes / (1024f * 1024f)
|
||||
val statusText = String.format(
|
||||
"Uploading: %.1f / %.1f MB (%d%%)",
|
||||
uploadedMB, totalMB, percentComplete
|
||||
)
|
||||
|
||||
updateNotification(statusText, percentComplete, 100)
|
||||
sendProgressBroadcast(bytesUploaded, totalBytes, percentComplete)
|
||||
Log.d(TAG, "Upload progress: $percentComplete% ($uploadedMB MB / $totalMB MB)")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChunkComplete(chunkIndex: Int, totalChunks: Int) {
|
||||
Log.d(TAG, "Chunk $chunkIndex/$totalChunks complete")
|
||||
}
|
||||
|
||||
override fun onUploadComplete(result: UploadResult) {
|
||||
Log.d(TAG, "Chunked upload complete: $result")
|
||||
|
||||
// Clear upload reference
|
||||
currentUploadManager = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
// Save to history directly if successful
|
||||
if (result.isSuccess) {
|
||||
saveToHistory(result)
|
||||
}
|
||||
|
||||
future.complete(result)
|
||||
}
|
||||
|
||||
override fun onError(error: String) {
|
||||
Log.e(TAG, "Chunked upload error: $error")
|
||||
|
||||
// Clear upload reference
|
||||
currentUploadManager = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
future.complete(UploadResult(error))
|
||||
}
|
||||
|
||||
override fun onPaused() {
|
||||
Log.d(TAG, "Chunked upload paused")
|
||||
// Note: updateNotification and sendPausedBroadcast are called in handlePauseUpload()
|
||||
}
|
||||
|
||||
override fun onResumed() {
|
||||
Log.d(TAG, "Chunked upload resumed")
|
||||
// Note: updateNotification and sendResumedBroadcast are called in handleResumeUpload()
|
||||
}
|
||||
})
|
||||
|
||||
// Start the upload - this triggers session initialization
|
||||
uploadManager.startUpload()
|
||||
.exceptionally { ex ->
|
||||
Log.e(TAG, "Exception during chunked upload", ex)
|
||||
future.complete(UploadResult("Exception during upload: ${ex.message}"))
|
||||
null
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a standard (non-chunked) upload for smaller files
|
||||
*/
|
||||
private fun performStandardUpload(
|
||||
file: File,
|
||||
maxDownloads: Int,
|
||||
expiryHours: Int
|
||||
): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
// Update notification immediately
|
||||
updateNotification("Uploading ${file.name}...", 0, 100)
|
||||
|
||||
// Create OkHttpClient with timeout configuration
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(NetworkConstants.CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.writeTimeout(NetworkConstants.WRITE_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.readTimeout(NetworkConstants.READ_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Using standard upload for file: ${file.name}")
|
||||
|
||||
val fileBody = file.asRequestBody("application/octet-stream".toMediaTypeOrNull())
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(NetworkConstants.PARAM_FILE, file.name, fileBody)
|
||||
.addFormDataPart(NetworkConstants.PARAM_MAX_DOWNLOADS, maxDownloads.toString())
|
||||
.addFormDataPart(NetworkConstants.PARAM_EXPIRY_HOURS, expiryHours.toString())
|
||||
.build()
|
||||
|
||||
// Get server URL and client key from preferences
|
||||
val serverUrl = PreferencesUtil.getServerUrl(this)
|
||||
val clientKey = PreferencesUtil.getClientKey(this)
|
||||
|
||||
Log.d(TAG, "Using server URL: $serverUrl")
|
||||
|
||||
// Build request with client-key header
|
||||
val request = Request.Builder()
|
||||
.url(serverUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Sending upload request with ${NetworkConstants.CLIENT_KEY_HEADER} header")
|
||||
|
||||
// Show initial progress
|
||||
updateNotification("Uploading ${file.name}...", 0, 100)
|
||||
|
||||
// Create call and store reference for cancellation
|
||||
val call = client.newCall(request)
|
||||
currentStandardUploadCall = call
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Upload failed", e)
|
||||
|
||||
// Clear upload reference
|
||||
currentStandardUploadCall = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
future.complete(UploadResult("Upload failed: ${e.message}"))
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
// Clear upload reference
|
||||
currentStandardUploadCall = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
Log.d(TAG, "Server response: $responseBody")
|
||||
|
||||
// Update progress to 100% on success
|
||||
updateNotification("Upload complete!", 100, 100)
|
||||
|
||||
// Parse JSON response to get the upload code
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val uploadCode = jsonResponse.getString("code")
|
||||
|
||||
Log.d(TAG, "Upload successful! Code: $uploadCode")
|
||||
|
||||
// Create the result
|
||||
val result = UploadResult(
|
||||
uploadCode,
|
||||
file.name,
|
||||
maxDownloads,
|
||||
expiryHours
|
||||
)
|
||||
|
||||
// Save to history
|
||||
saveToHistory(result)
|
||||
|
||||
future.complete(result)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse server response", e)
|
||||
future.complete(UploadResult("Failed to parse server response"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
val errorMsg = when (response.code) {
|
||||
401, 403 -> {
|
||||
Log.e(TAG, "Authentication error: ${response.code}, Body: $errorBody")
|
||||
"Authentication failed. Please check client key."
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Upload failed: ${response.code}, Body: $errorBody")
|
||||
"Upload failed, Response code: ${response.code}"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract error message from JSON
|
||||
val finalErrorMsg = try {
|
||||
val errorJson = JSONObject(errorBody)
|
||||
errorJson.optString("error", errorMsg)
|
||||
} catch (e: JSONException) {
|
||||
errorMsg
|
||||
}
|
||||
|
||||
future.complete(UploadResult(finalErrorMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an upload result to the history database
|
||||
*/
|
||||
private fun saveToHistory(result: UploadResult) {
|
||||
if (!result.isSuccess) {
|
||||
return
|
||||
}
|
||||
|
||||
val historyEntry = UploadHistory(
|
||||
fileName = result.fileName,
|
||||
uploadTime = System.currentTimeMillis(),
|
||||
uploadCode = result.code,
|
||||
maxDownloads = result.maxDownloads,
|
||||
expiryHours = result.expiryHours
|
||||
)
|
||||
|
||||
repository.insert(historyEntry)
|
||||
.thenAccept { id -> Log.d(TAG, "Saved to history with ID: $id") }
|
||||
.exceptionally { ex ->
|
||||
Log.e(TAG, "Error saving to history", ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial notification
|
||||
*/
|
||||
private fun createInitialNotification(): Notification {
|
||||
val notificationIntent = Intent(this, ComposeMainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, AppConstants.NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("File Upload")
|
||||
.setContentText("Preparing file...")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setProgress(100, 0, true) // Start with indeterminate progress
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true) // Don't alert on every progress update
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH) // High priority for better visibility
|
||||
.addAction(createCancelAction()) // Add cancel action from the start
|
||||
.apply {
|
||||
// Force immediate notification display on Android 14+ (prevents system delay)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
}
|
||||
}
|
||||
|
||||
notificationBuilder = builder
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification with progress
|
||||
*
|
||||
* @param status The status message
|
||||
* @param progress Current progress value (-1 for indeterminate)
|
||||
* @param max Maximum progress value
|
||||
* @param isComplete Whether the upload is complete (makes notification dismissible)
|
||||
*/
|
||||
private fun updateNotification(status: String, progress: Int = -1, max: Int = 100, isComplete: Boolean = false) {
|
||||
val builder = notificationBuilder ?: return
|
||||
|
||||
Log.d(TAG, "Updating notification: $status (progress: $progress/$max)")
|
||||
|
||||
// Ignore updates if upload is cancelled (unless this IS the cancellation message)
|
||||
if (uploadState == UploadState.CANCELLED && !isComplete) {
|
||||
Log.d(TAG, "Ignoring notification update because upload is cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
builder.setContentText(status)
|
||||
|
||||
if (progress >= 0 && !isComplete) {
|
||||
// Determinate progress
|
||||
builder.setProgress(max, progress, false)
|
||||
} else if (!isComplete) {
|
||||
// Indeterminate progress
|
||||
builder.setProgress(max, 0, true)
|
||||
}
|
||||
|
||||
// Add action buttons based on upload state
|
||||
if (!isComplete && uploadState != UploadState.CANCELLED) {
|
||||
builder.clearActions() // Remove old actions
|
||||
|
||||
when (uploadState) {
|
||||
UploadState.PREPARING -> {
|
||||
// Show Cancel button during file preparation/zipping
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
UploadState.PAUSED -> {
|
||||
// Show Resume and Cancel buttons
|
||||
builder.addAction(createResumeAction())
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
UploadState.UPLOADING -> {
|
||||
// Show Pause (if chunked) and Cancel buttons
|
||||
if (currentUploadManager != null) {
|
||||
builder.addAction(createPauseAction())
|
||||
}
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
else -> {
|
||||
// For IDLE or other states, just show Cancel
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When complete or cancelled, make notification dismissible and remove progress bar
|
||||
if (isComplete || uploadState == UploadState.CANCELLED) {
|
||||
builder.clearActions() // Remove all actions
|
||||
builder.setOngoing(false)
|
||||
.setProgress(0, 0, false) // Remove progress bar
|
||||
.setAutoCancel(true) // Allow swipe to dismiss
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification with upload complete status and code
|
||||
*
|
||||
* @param status The status message
|
||||
* @param uploadCode The upload code to display
|
||||
*/
|
||||
private fun updateNotificationComplete(status: String, uploadCode: String) {
|
||||
val builder = notificationBuilder ?: return
|
||||
|
||||
Log.d(TAG, "Updating notification with upload code: $uploadCode")
|
||||
|
||||
builder.setContentTitle("Upload Successful!")
|
||||
.setContentText(status)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(status))
|
||||
.setProgress(0, 0, false) // Remove progress bar
|
||||
.setOngoing(false) // Not ongoing, can be dismissed
|
||||
.setAutoCancel(true) // Swipe to dismiss
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the notification channel (required for Android 8.0+)
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name: CharSequence = "File Upload Channel"
|
||||
val description = "Notification channel for file upload progress"
|
||||
val importance = NotificationManager.IMPORTANCE_HIGH // Changed to HIGH for better visibility
|
||||
val channel = NotificationChannel(
|
||||
AppConstants.NOTIFICATION_CHANNEL_ID,
|
||||
name,
|
||||
importance
|
||||
).apply {
|
||||
this.description = description
|
||||
setShowBadge(true)
|
||||
enableVibration(false) // Don't vibrate on progress updates
|
||||
enableLights(false) // Don't flash lights on progress updates
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with the upload success result
|
||||
*/
|
||||
private fun sendSuccessBroadcast(uploadCode: String) {
|
||||
Log.d(TAG, "Sending success broadcast with code: $uploadCode")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_COMPLETE).apply {
|
||||
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
|
||||
*/
|
||||
private fun sendErrorBroadcast(errorMessage: String) {
|
||||
Log.e(TAG, "Sending error broadcast: $errorMessage")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_ERROR).apply {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with upload progress update
|
||||
*/
|
||||
private fun sendProgressBroadcast(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) {
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_PROGRESS).apply {
|
||||
putExtra(AppConstants.EXTRA_BYTES_UPLOADED, bytesUploaded)
|
||||
putExtra(AppConstants.EXTRA_TOTAL_BYTES, totalBytes)
|
||||
putExtra(AppConstants.EXTRA_PERCENT_COMPLETE, percentComplete)
|
||||
}
|
||||
|
||||
// Send local broadcast to the ViewModel
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with zip/compression progress update
|
||||
*/
|
||||
private fun sendZipProgressBroadcast(percentComplete: Int) {
|
||||
val intent = Intent(AppConstants.ACTION_ZIP_PROGRESS).apply {
|
||||
putExtra(AppConstants.EXTRA_PERCENT_COMPLETE, percentComplete)
|
||||
}
|
||||
|
||||
// Send local broadcast to the ViewModel
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is paused
|
||||
*/
|
||||
private fun sendPausedBroadcast() {
|
||||
Log.d(TAG, "Sending paused broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_PAUSED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is resumed
|
||||
*/
|
||||
private fun sendResumedBroadcast() {
|
||||
Log.d(TAG, "Sending resumed broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_RESUMED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is cancelled
|
||||
*/
|
||||
private fun sendCancelledBroadcast() {
|
||||
Log.d(TAG, "Sending cancelled broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_CANCELLED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pause action for the notification
|
||||
*/
|
||||
private fun createPauseAction(): NotificationCompat.Action {
|
||||
val pauseIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_PAUSE_UPLOAD
|
||||
}
|
||||
val pausePendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
pauseIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
pausePendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resume action for the notification
|
||||
*/
|
||||
private fun createResumeAction(): NotificationCompat.Action {
|
||||
val resumeIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_RESUME_UPLOAD
|
||||
}
|
||||
val resumePendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
resumeIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Resume",
|
||||
resumePendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancel action for the notification
|
||||
*/
|
||||
private fun createCancelAction(): NotificationCompat.Action {
|
||||
val cancelIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_CANCEL_UPLOAD
|
||||
}
|
||||
val cancelPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
3,
|
||||
cancelIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"Cancel",
|
||||
cancelPendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pause upload request
|
||||
*/
|
||||
private fun handlePauseUpload() {
|
||||
Log.d(TAG, "Handling pause upload request")
|
||||
|
||||
if (uploadState != UploadState.UPLOADING) {
|
||||
Log.w(TAG, "Cannot pause: upload not in progress (state: $uploadState)")
|
||||
return
|
||||
}
|
||||
|
||||
uploadState = UploadState.PAUSED
|
||||
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.pauseUpload()
|
||||
updateNotification("Upload paused", -1, 100)
|
||||
sendPausedBroadcast()
|
||||
Log.d(TAG, "Chunked upload paused successfully")
|
||||
} ?: run {
|
||||
Log.w(TAG, "Cannot pause standard upload (not supported)")
|
||||
updateNotification("Cannot pause standard upload", -1, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resume upload request
|
||||
*/
|
||||
private fun handleResumeUpload() {
|
||||
Log.d(TAG, "Handling resume upload request")
|
||||
|
||||
if (uploadState != UploadState.PAUSED) {
|
||||
Log.w(TAG, "Cannot resume: upload not paused (state: $uploadState)")
|
||||
return
|
||||
}
|
||||
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.resumeUpload()
|
||||
updateNotification("Resuming upload...", -1, 100)
|
||||
sendResumedBroadcast()
|
||||
Log.d(TAG, "Chunked upload resumed successfully")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel upload request
|
||||
*/
|
||||
private fun handleCancelUpload() {
|
||||
Log.d(TAG, "Handling cancel upload request")
|
||||
|
||||
uploadState = UploadState.CANCELLED
|
||||
|
||||
// Cancel file preparation (zipping) if in progress
|
||||
FileController.cancelPreparation()
|
||||
|
||||
// Cancel chunked upload
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.cancelUpload()
|
||||
currentUploadManager = null
|
||||
Log.d(TAG, "Cancelled chunked upload")
|
||||
}
|
||||
|
||||
// Cancel standard upload
|
||||
currentStandardUploadCall?.let { call ->
|
||||
call.cancel()
|
||||
currentStandardUploadCall = null
|
||||
Log.d(TAG, "Cancelled standard upload")
|
||||
}
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Send cancelled broadcast
|
||||
sendCancelledBroadcast()
|
||||
|
||||
// Update notification
|
||||
updateNotification("Upload cancelled", -1, 100, isComplete = true)
|
||||
|
||||
// Stop service after a short delay
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false)
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.mattintech.simplelogupload.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
|
||||
/**
|
||||
* Dialog for entering upload parameters
|
||||
*
|
||||
* @param onDismiss Callback when dialog is dismissed
|
||||
* @param onConfirm Callback when upload is confirmed with (maxDownloads, expiryHours)
|
||||
*/
|
||||
@Composable
|
||||
fun UploadParamsDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int, Int) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var maxDownloads by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultMaxDownloads(context).toString())
|
||||
}
|
||||
var expiryHours by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultExpiryHours(context).toString())
|
||||
}
|
||||
|
||||
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
|
||||
var expiryHoursError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validateAndConfirm() {
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
val maxDl = maxDownloads.toIntOrNull()
|
||||
if (maxDl == null || maxDl < 0) {
|
||||
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
val expiry = expiryHours.toIntOrNull()
|
||||
if (expiry == null || expiry <= 0 || expiry > 24) {
|
||||
expiryHoursError = "Must be between 1 and 24 hours"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onConfirm(maxDl!!, expiry!!)
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Upload Parameters") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Instructions
|
||||
Text(
|
||||
text = "Edit the values below to customize upload settings:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Max Downloads
|
||||
OutlinedTextField(
|
||||
value = maxDownloads,
|
||||
onValueChange = {
|
||||
maxDownloads = it
|
||||
maxDownloadsError = null
|
||||
},
|
||||
label = { Text("Maximum Downloads (tap to edit)") },
|
||||
placeholder = { Text("Enter number") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit max downloads",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
|
||||
},
|
||||
isError = maxDownloadsError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
// Expiry Hours
|
||||
OutlinedTextField(
|
||||
value = expiryHours,
|
||||
onValueChange = {
|
||||
expiryHours = it
|
||||
expiryHoursError = null
|
||||
},
|
||||
label = { Text("Expiry Time in hours (tap to edit)") },
|
||||
placeholder = { Text("Enter hours") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit expiry hours",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
|
||||
},
|
||||
isError = expiryHoursError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { validateAndConfirm() }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { validateAndConfirm() }) {
|
||||
Text("Upload")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.mattintech.simplelogupload.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Dialog for displaying upload success result with upload code
|
||||
*
|
||||
* @param uploadCode The upload code to display
|
||||
* @param onDismiss Callback when dialog is dismissed
|
||||
*/
|
||||
@Composable
|
||||
fun UploadResultDialog(
|
||||
uploadCode: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
fun copyCodeToClipboard() {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Upload Code", uploadCode)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Code copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Upload Successful!",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Your upload code is:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
// Upload code in a card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = uploadCode,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
IconButton(onClick = { copyCodeToClipboard() }) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Share this code to allow others to download your file.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mattintech.simplelogupload.ui.screens.tabs.DumpstateTab
|
||||
import com.mattintech.simplelogupload.ui.screens.tabs.GeneralUploadTab
|
||||
import com.mattintech.simplelogupload.util.DeviceUtil
|
||||
import com.mattintech.simplelogupload.viewmodel.MainViewModel
|
||||
|
||||
/**
|
||||
* Main screen with upload functionality and bottom navigation
|
||||
* Shows different tabs based on device type (Samsung vs others)
|
||||
*
|
||||
* @param onNavigateToHistory Callback to navigate to upload history
|
||||
* @param onNavigateToSettings Callback to navigate to settings
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onNavigateToHistory: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
viewModel: MainViewModel = viewModel()
|
||||
) {
|
||||
val isSamsungDevice = remember { DeviceUtil.isSamsungDevice() }
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SimpleLogUpload") },
|
||||
actions = {
|
||||
// History button
|
||||
IconButton(onClick = onNavigateToHistory) {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = "Upload History"
|
||||
)
|
||||
}
|
||||
|
||||
// Settings button
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Only show bottom navigation on Samsung devices
|
||||
if (isSamsungDevice) {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTabIndex == 0,
|
||||
onClick = { selectedTabIndex = 0 },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Upload,
|
||||
contentDescription = "General Upload"
|
||||
)
|
||||
},
|
||||
label = { Text("Upload") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTabIndex == 1,
|
||||
onClick = { selectedTabIndex = 1 },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.BugReport,
|
||||
contentDescription = "Dumpstate"
|
||||
)
|
||||
},
|
||||
label = { Text("Dumpstate") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Global upload progress indicator - visible across all tabs
|
||||
if (uiState.isLoading) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (uiState.isPaused) "Upload Paused" else "Uploading",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
if (uiState.totalBytes > 0) {
|
||||
Text(
|
||||
text = "${uiState.uploadProgress}%",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Progress bar
|
||||
LinearProgressIndicator(
|
||||
progress = { uiState.uploadProgress / 100f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
)
|
||||
|
||||
// Progress details
|
||||
if (uiState.totalBytes > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uploadedMB = uiState.bytesUploaded / (1024f * 1024f)
|
||||
val totalMB = uiState.totalBytes / (1024f * 1024f)
|
||||
Text(
|
||||
text = String.format("%.1f / %.1f MB", uploadedMB, totalMB),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
// Status message
|
||||
uiState.uploadStatusMessage?.let { message ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (uiState.isPaused) {
|
||||
// Show Resume button
|
||||
TextButton(
|
||||
onClick = { viewModel.resumeUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Resume",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Resume")
|
||||
}
|
||||
} else {
|
||||
// Show Pause button (only for chunked uploads)
|
||||
if (uiState.isChunkedUpload) {
|
||||
TextButton(
|
||||
onClick = { viewModel.pauseUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Pause,
|
||||
contentDescription = "Pause",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Cancel button (always shown)
|
||||
TextButton(
|
||||
onClick = { viewModel.cancelUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Cancel",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
when {
|
||||
// Non-Samsung device: show only general upload
|
||||
!isSamsungDevice -> {
|
||||
GeneralUploadTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// Samsung device with tab 0 selected: general upload
|
||||
selectedTabIndex == 0 -> {
|
||||
GeneralUploadTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// Samsung device with tab 1 selected: dumpstate
|
||||
else -> {
|
||||
DumpstateTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Onboarding screen with carousel explaining app and permissions
|
||||
*
|
||||
* @param onOnboardingComplete Callback when onboarding is complete
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onOnboardingComplete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val pagerState = rememberPagerState(pageCount = { 5 })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Permission states
|
||||
var notificationPermissionGranted by remember {
|
||||
mutableStateOf(PermissionUtil.hasNotificationPermission(context))
|
||||
}
|
||||
var filesPermissionGranted by remember {
|
||||
mutableStateOf(PermissionUtil.hasAllFilesAccessPermission())
|
||||
}
|
||||
|
||||
// Server configuration states
|
||||
var serverAddress by remember { mutableStateOf("") }
|
||||
var serverPort by remember { mutableStateOf("") }
|
||||
var clientKey by remember { mutableStateOf("") }
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Notification permission launcher
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
notificationPermissionGranted = isGranted
|
||||
if (isGranted) {
|
||||
// Auto-advance to next page after permission granted
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Notification permission helps you track upload progress",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
// Still allow advancing even if denied
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files access launcher
|
||||
val filesPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
filesPermissionGranted = PermissionUtil.hasAllFilesAccessPermission()
|
||||
if (filesPermissionGranted) {
|
||||
// Auto-advance to server setup page
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"File access is needed to upload files from your device",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
// Still allow advancing even if denied
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher (for QR scanning)
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f),
|
||||
userScrollEnabled = false // Disable manual scrolling
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> WelcomePage(
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
1 -> NotificationPermissionPage(
|
||||
isGranted = notificationPermissionGranted,
|
||||
onRequestPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
// On older versions, permission is granted by default
|
||||
notificationPermissionGranted = true
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
2 -> FilesPermissionPage(
|
||||
isGranted = filesPermissionGranted,
|
||||
onRequestPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
filesPermissionLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
filesPermissionLauncher.launch(intent)
|
||||
}
|
||||
} else {
|
||||
// On older versions, permission is granted by default
|
||||
filesPermissionGranted = true
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
3 -> ServerSetupPage(
|
||||
serverAddress = serverAddress,
|
||||
serverPort = serverPort,
|
||||
clientKey = clientKey,
|
||||
serverAddressError = serverAddressError,
|
||||
serverPortError = serverPortError,
|
||||
clientKeyError = clientKeyError,
|
||||
onServerAddressChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
onServerPortChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
onClientKeyChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
onScanQr = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
onContinue = {
|
||||
// Validate server settings
|
||||
var hasError = false
|
||||
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setSetupCompleted(context, true)
|
||||
|
||||
// Advance to final page
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
4 -> ReadyPage(
|
||||
onGetStarted = {
|
||||
// Mark onboarding as complete
|
||||
PreferencesUtil.setOnboardingCompleted(context, true)
|
||||
onOnboardingComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Page indicator
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
repeat(5) { iteration ->
|
||||
val color = if (pagerState.currentPage == iteration) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(8.dp)
|
||||
.background(color, shape = MaterialTheme.shapes.small)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome page explaining the app
|
||||
*/
|
||||
@Composable
|
||||
private fun WelcomePage(
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
title = "Welcome to SimpleLogUpload",
|
||||
description = "A simple and secure way to upload files to your server.\n\n" +
|
||||
"This app requires a backend server to function. Your administrator should have " +
|
||||
"provided you with server details.\n\n" +
|
||||
"Let's get your app set up!",
|
||||
buttonText = "Continue",
|
||||
onButtonClick = onContinue
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification permission page
|
||||
*/
|
||||
@Composable
|
||||
private fun NotificationPermissionPage(
|
||||
isGranted: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Notifications,
|
||||
title = "Upload Progress Notifications",
|
||||
description = "SimpleLogUpload needs notification permission to:\n\n" +
|
||||
"• Show upload progress in real-time\n" +
|
||||
"• Display upload completion status\n" +
|
||||
"• Keep you informed about file transfers\n\n" +
|
||||
(if (isGranted) "✓ Permission granted!" else "This helps you track uploads even when the app is in the background."),
|
||||
buttonText = if (isGranted) "Continue" else "Grant Permission",
|
||||
onButtonClick = onRequestPermission,
|
||||
isPermissionGranted = isGranted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files permission page
|
||||
*/
|
||||
@Composable
|
||||
private fun FilesPermissionPage(
|
||||
isGranted: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Folder,
|
||||
title = "File Access Permission",
|
||||
description = "SimpleLogUpload needs access to your files to:\n\n" +
|
||||
"• Browse and select files to upload\n" +
|
||||
"• Access dumpstate files (on Samsung devices)\n" +
|
||||
"• Read files from any location on your device\n\n" +
|
||||
(if (isGranted) "✓ Permission granted!" else "You'll be taken to Settings to grant this permission."),
|
||||
buttonText = if (isGranted) "Continue" else "Grant Permission",
|
||||
onButtonClick = onRequestPermission,
|
||||
isPermissionGranted = isGranted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server setup page
|
||||
*/
|
||||
@Composable
|
||||
private fun ServerSetupPage(
|
||||
serverAddress: String,
|
||||
serverPort: String,
|
||||
clientKey: String,
|
||||
serverAddressError: String?,
|
||||
serverPortError: String?,
|
||||
clientKeyError: String?,
|
||||
onServerAddressChange: (String) -> Unit,
|
||||
onServerPortChange: (String) -> Unit,
|
||||
onClientKeyChange: (String) -> Unit,
|
||||
onScanQr: () -> Unit,
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Storage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your server details or scan a QR code from your admin panel.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = onServerAddressChange,
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = onServerPortChange,
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = onClientKeyChange,
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = onScanQr,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Continue",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready page - final page
|
||||
*/
|
||||
@Composable
|
||||
private fun ReadyPage(
|
||||
onGetStarted: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Check,
|
||||
title = "You're All Set!",
|
||||
description = "Your app is now configured and ready to use!\n\n" +
|
||||
"✓ Permissions granted\n" +
|
||||
"✓ Server configured\n\n" +
|
||||
"You can now upload files to your server. " +
|
||||
"Tap the button below to start using SimpleLogUpload!",
|
||||
buttonText = "Start Using App",
|
||||
onButtonClick = onGetStarted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable template for onboarding pages
|
||||
*/
|
||||
@Composable
|
||||
private fun OnboardingPageTemplate(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
buttonText: String,
|
||||
onButtonClick: () -> Unit,
|
||||
isPermissionGranted: Boolean = false
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onButtonClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = buttonText,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Server setup screen for configuring server connection
|
||||
*
|
||||
* @param onSetupComplete Callback when setup is successfully completed
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerSetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var serverAddress by remember { mutableStateOf("") }
|
||||
var serverPort by remember { mutableStateOf("") }
|
||||
var clientKey by remember { mutableStateOf("") }
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setSetupCompleted(context, true)
|
||||
|
||||
Toast.makeText(context, "Setup completed successfully!", Toast.LENGTH_SHORT).show()
|
||||
onSetupComplete()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Server Setup") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Instructions card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your server connection details below, or scan a QR code from your server admin panel to auto-fill.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Finish Setup Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Finish Setup",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Settings screen for modifying server configuration
|
||||
*
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Load existing settings
|
||||
var serverAddress by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerAddress(context))
|
||||
}
|
||||
var serverPort by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerPort(context).toString())
|
||||
}
|
||||
var clientKey by remember {
|
||||
mutableStateOf(PreferencesUtil.getClientKey(context))
|
||||
}
|
||||
var defaultMaxDownloads by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultMaxDownloads(context).toString())
|
||||
}
|
||||
var defaultExpiryHours by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultExpiryHours(context).toString())
|
||||
}
|
||||
var askBeforeUpload by remember {
|
||||
mutableStateOf(PreferencesUtil.getAskBeforeUpload(context))
|
||||
}
|
||||
var enableParallelUploads by remember {
|
||||
mutableStateOf(PreferencesUtil.isParallelUploadsEnabled(context))
|
||||
}
|
||||
var clientKeyVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
|
||||
var expiryHoursError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate max downloads
|
||||
val maxDl = defaultMaxDownloads.toIntOrNull()
|
||||
if (maxDl == null || maxDl < 0) {
|
||||
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate expiry hours
|
||||
val expiry = defaultExpiryHours.toIntOrNull()
|
||||
if (expiry == null || expiry <= 0 || expiry > 24) {
|
||||
expiryHoursError = "Must be between 1 and 24 hours"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setDefaultMaxDownloads(context, maxDl!!)
|
||||
PreferencesUtil.setDefaultExpiryHours(context, expiry!!)
|
||||
PreferencesUtil.setAskBeforeUpload(context, askBeforeUpload)
|
||||
PreferencesUtil.setParallelUploadsEnabled(context, enableParallelUploads)
|
||||
|
||||
Toast.makeText(context, "Settings saved successfully!", Toast.LENGTH_SHORT).show()
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
fun resetSettings() {
|
||||
// Reload original values
|
||||
serverAddress = PreferencesUtil.getServerAddress(context)
|
||||
serverPort = PreferencesUtil.getServerPort(context).toString()
|
||||
clientKey = PreferencesUtil.getClientKey(context)
|
||||
defaultMaxDownloads = PreferencesUtil.getDefaultMaxDownloads(context).toString()
|
||||
defaultExpiryHours = PreferencesUtil.getDefaultExpiryHours(context).toString()
|
||||
askBeforeUpload = PreferencesUtil.getAskBeforeUpload(context)
|
||||
enableParallelUploads = PreferencesUtil.isParallelUploadsEnabled(context)
|
||||
|
||||
// Clear errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
Toast.makeText(context, "Settings reset", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
visualTransformation = if (clientKeyVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { clientKeyVisible = !clientKeyVisible }) {
|
||||
Icon(
|
||||
imageVector = if (clientKeyVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (clientKeyVisible) "Hide client key" else "Show client key"
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code to Auto-Fill")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Upload Defaults Section
|
||||
Text(
|
||||
text = "Upload Defaults",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Default Max Downloads
|
||||
OutlinedTextField(
|
||||
value = defaultMaxDownloads,
|
||||
onValueChange = {
|
||||
defaultMaxDownloads = it
|
||||
maxDownloadsError = null
|
||||
},
|
||||
label = { Text("Default Maximum Downloads") },
|
||||
placeholder = { Text("5") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = maxDownloadsError != null,
|
||||
supportingText = {
|
||||
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Default Expiry Hours
|
||||
OutlinedTextField(
|
||||
value = defaultExpiryHours,
|
||||
onValueChange = {
|
||||
defaultExpiryHours = it
|
||||
expiryHoursError = null
|
||||
},
|
||||
label = { Text("Default Expiry Time (hours)") },
|
||||
placeholder = { Text("24") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = expiryHoursError != null,
|
||||
supportingText = {
|
||||
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Ask Before Upload Switch
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Ask Before Upload",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (askBeforeUpload) "Show upload parameters dialog" else "Use default settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = askBeforeUpload,
|
||||
onCheckedChange = { askBeforeUpload = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Parallel Uploads Switch
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Upload Mode",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (enableParallelUploads)
|
||||
"Parallel: 2 chunks simultaneously (Experimental)"
|
||||
else
|
||||
"Sequential: One chunk at a time (Recommended)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enableParallelUploads,
|
||||
onCheckedChange = { enableParallelUploads = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Reset Button
|
||||
OutlinedButton(
|
||||
onClick = { resetSettings() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mattintech.simplelogupload.model.UploadHistory
|
||||
import com.mattintech.simplelogupload.viewmodel.UploadHistoryViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Upload history screen showing past uploads
|
||||
*
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UploadHistoryScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: UploadHistoryViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val history by viewModel.allHistory.observeAsState(emptyList())
|
||||
val deleteStatus by viewModel.deleteStatus.observeAsState()
|
||||
|
||||
// Show toast on delete status
|
||||
LaunchedEffect(deleteStatus) {
|
||||
deleteStatus?.let { success ->
|
||||
val message = if (success) {
|
||||
"Upload deleted successfully"
|
||||
} else {
|
||||
"Failed to delete upload"
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Upload History") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (history.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "No upload history",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Your uploaded files will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// History list
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(history, key = { it.id.toString() }) { item ->
|
||||
UploadHistoryItem(
|
||||
history = item,
|
||||
onCopyCode = { code ->
|
||||
copyToClipboard(context, code)
|
||||
Toast.makeText(context, "Code copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDelete = { uploadHistory ->
|
||||
viewModel.delete(uploadHistory)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual upload history item
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadHistoryItem(
|
||||
history: UploadHistory,
|
||||
onCopyCode: (String) -> Unit,
|
||||
onDelete: (UploadHistory) -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Filename
|
||||
Text(
|
||||
text = history.fileName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Upload code
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Code: ${history.uploadCode}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = formatDate(history.uploadTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
// Copy button
|
||||
IconButton(onClick = { onCopyCode(history.uploadCode) }) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Upload") },
|
||||
text = { Text("Are you sure you want to delete this upload? This will also remove the file from the server.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete(history)
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Upload Code", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.R
|
||||
|
||||
/**
|
||||
* Welcome screen shown on first launch
|
||||
* Guides users to server setup
|
||||
*
|
||||
* @param onNavigateToSetup Callback when user clicks continue button
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onNavigateToSetup: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Top section with logo and title
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// App logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_icon),
|
||||
contentDescription = "SimpleLogUpload logo",
|
||||
modifier = Modifier.size(120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Welcome title
|
||||
Text(
|
||||
text = "Welcome to SimpleLogUpload",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Description
|
||||
Text(
|
||||
text = "Before you can use this app, you need to configure it to connect to your SimpleLogUpload server instance.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Requirements card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "The app requires:",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
RequirementItem(
|
||||
title = "Server Address",
|
||||
description = "IP or hostname of your server"
|
||||
)
|
||||
|
||||
RequirementItem(
|
||||
title = "Server Port",
|
||||
description = "Port your server is running on"
|
||||
)
|
||||
|
||||
RequirementItem(
|
||||
title = "Client Key",
|
||||
description = "Authentication key for secure uploads"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Your administrator should have provided these details.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom button
|
||||
Button(
|
||||
onClick = onNavigateToSetup,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Continue to Setup",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual requirement item with bullet point
|
||||
*/
|
||||
@Composable
|
||||
private fun RequirementItem(
|
||||
title: String,
|
||||
description: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
Text(
|
||||
text = "• ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.mattintech.simplelogupload.ui.screens.tabs
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
|
||||
import com.mattintech.simplelogupload.ui.components.UploadResultDialog
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.viewmodel.MainViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Dumpstate upload tab for Samsung devices
|
||||
* Shows detected dumpstate files with multi-select
|
||||
*
|
||||
* @param modifier Modifier for the composable
|
||||
* @param viewModel MainViewModel instance
|
||||
*/
|
||||
@Composable
|
||||
fun DumpstateTab(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// State to track pending upload parameters
|
||||
var pendingUpload by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
|
||||
// Notification permission launcher
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Permission granted, proceed with pending upload
|
||||
pendingUpload?.let { (maxDownloads, expiryHours) ->
|
||||
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
|
||||
}
|
||||
pendingUpload = null
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Notification permission is required to show upload progress",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check permission and upload
|
||||
val uploadWithPermissionCheck: (Int, Int) -> Unit = { maxDownloads, expiryHours ->
|
||||
if (PermissionUtil.hasNotificationPermission(context)) {
|
||||
// Permission already granted, proceed with upload
|
||||
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
|
||||
} else {
|
||||
// Need to request permission first
|
||||
pendingUpload = Pair(maxDownloads, expiryHours)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Header card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Samsung Dumpstate Files",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = when {
|
||||
uiState.dumpstateFiles.isEmpty() -> "No dumpstate files found on this device"
|
||||
uiState.dumpstateFiles.size == 1 -> "1 dumpstate file found"
|
||||
else -> "${uiState.dumpstateFiles.size} dumpstate files found"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when {
|
||||
uiState.dumpstateFiles.isEmpty() -> {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "No dumpstate files detected",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Generate a dumpstate file from your device settings",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { viewModel.loadDumpstateFiles() }) {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Selection controls
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = { viewModel.selectAllDumpstates() }) {
|
||||
Text("Select All")
|
||||
}
|
||||
TextButton(onClick = { viewModel.clearDumpstateSelections() }) {
|
||||
Text("Clear All")
|
||||
}
|
||||
}
|
||||
|
||||
// File list
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(uiState.dumpstateFiles) { index, file ->
|
||||
DumpstateFileItem(
|
||||
file = file,
|
||||
isSelected = uiState.selectedDumpstateIndices.contains(index),
|
||||
onToggle = { viewModel.toggleDumpstateSelection(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Upload button
|
||||
Button(
|
||||
onClick = {
|
||||
if (PreferencesUtil.getAskBeforeUpload(context)) {
|
||||
viewModel.showUploadParamsDialog()
|
||||
} else {
|
||||
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
|
||||
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading && uiState.selectedDumpstateIndices.isNotEmpty()
|
||||
) {
|
||||
val count = uiState.selectedDumpstateIndices.size
|
||||
Text(
|
||||
text = if (count > 0) "Upload Selected Files ($count)" else "Select Files to Upload",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload params dialog
|
||||
if (uiState.showUploadParamsDialog) {
|
||||
UploadParamsDialog(
|
||||
onDismiss = { viewModel.hideUploadParamsDialog() },
|
||||
onConfirm = { maxDownloads, expiryHours ->
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload result dialog
|
||||
if (uiState.showUploadResultDialog) {
|
||||
uiState.uploadResult?.let { result ->
|
||||
UploadResultDialog(
|
||||
uploadCode = result.code,
|
||||
onDismiss = { viewModel.hideUploadResultDialog() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual dumpstate file item with checkbox
|
||||
*/
|
||||
@Composable
|
||||
private fun DumpstateFileItem(
|
||||
file: java.io.File,
|
||||
isSelected: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onToggle() }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Size: ${formatFileSize(file.length())} • ${formatDate(file.lastModified())}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human-readable string
|
||||
*/
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
|
||||
else -> "${bytes / (1024 * 1024 * 1024)} GB"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.mattintech.simplelogupload.ui.screens.tabs
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
|
||||
import com.mattintech.simplelogupload.ui.components.UploadResultDialog
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.viewmodel.MainViewModel
|
||||
|
||||
/**
|
||||
* General file upload tab
|
||||
* Allows uploading any file from device storage
|
||||
*
|
||||
* @param modifier Modifier for the composable
|
||||
* @param viewModel MainViewModel instance
|
||||
*/
|
||||
@Composable
|
||||
fun GeneralUploadTab(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// State to track pending upload parameters
|
||||
var pendingUpload by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
|
||||
// Notification permission launcher
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Permission granted, proceed with pending upload
|
||||
pendingUpload?.let { (maxDownloads, expiryHours) ->
|
||||
uiState.selectedFile?.let { file ->
|
||||
viewModel.uploadFile(file, maxDownloads, expiryHours)
|
||||
}
|
||||
}
|
||||
pendingUpload = null
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Notification permission is required to show upload progress",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// File picker launcher
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
viewModel.handleFileSelection(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings launcher for when user returns from granting all files access
|
||||
val settingsLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { _ ->
|
||||
// Check if permission was granted after returning from settings
|
||||
if (PermissionUtil.hasAllFilesAccessPermission()) {
|
||||
filePickerLauncher.launch("*/*")
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"All files access permission is required to select files",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check permission and upload
|
||||
val uploadWithPermissionCheck: (Int, Int) -> Unit = { maxDownloads, expiryHours ->
|
||||
if (PermissionUtil.hasNotificationPermission(context)) {
|
||||
// Permission already granted, proceed with upload
|
||||
uiState.selectedFile?.let { file ->
|
||||
viewModel.uploadFile(file, maxDownloads, expiryHours)
|
||||
}
|
||||
} else {
|
||||
// Need to request permission first
|
||||
pendingUpload = Pair(maxDownloads, expiryHours)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Instructions card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Upload Files",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Select a file from your device to upload to your server. You'll receive a code to share for downloading the file.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Select file button
|
||||
Button(
|
||||
onClick = {
|
||||
if (PermissionUtil.hasAllFilesAccessPermission()) {
|
||||
filePickerLauncher.launch("*/*")
|
||||
} else {
|
||||
// Request all files access permission by opening settings
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
settingsLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
// Fallback to general all files access settings
|
||||
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
settingsLauncher.launch(intent)
|
||||
}
|
||||
} else {
|
||||
// On older Android versions, just launch file picker
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Upload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Select File from Storage",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Selected file info
|
||||
uiState.selectedFileName?.let { fileName ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Selected File",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (PreferencesUtil.getAskBeforeUpload(context)) {
|
||||
viewModel.showUploadParamsDialog()
|
||||
} else {
|
||||
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
|
||||
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Text("Upload File")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Upload params dialog
|
||||
if (uiState.showUploadParamsDialog) {
|
||||
UploadParamsDialog(
|
||||
onDismiss = { viewModel.hideUploadParamsDialog() },
|
||||
onConfirm = { maxDownloads, expiryHours ->
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload result dialog
|
||||
if (uiState.showUploadResultDialog) {
|
||||
uiState.uploadResult?.let { result ->
|
||||
UploadResultDialog(
|
||||
uploadCode = result.code,
|
||||
onDismiss = { viewModel.hideUploadResultDialog() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Google Blue Primary (matching app icon)
|
||||
val GoogleBlue = Color(0xFF4285F4)
|
||||
val GoogleBlueDark = Color(0xFF1A73E8)
|
||||
val GoogleBlueLight = Color(0xFF669DF6)
|
||||
|
||||
// Google Green Accent (matching app icon)
|
||||
val GoogleGreen = Color(0xFF34A853)
|
||||
val GoogleGreenDark = Color(0xFF0F9D58)
|
||||
val GoogleGreenLight = Color(0xFF57BB8A)
|
||||
|
||||
// Neutral colors
|
||||
val White = Color(0xFFFFFFFF)
|
||||
val Black = Color(0xFF000000)
|
||||
val Gray50 = Color(0xFFF8F9FA)
|
||||
val Gray100 = Color(0xFFF1F3F4)
|
||||
val Gray200 = Color(0xFFE8EAED)
|
||||
val Gray700 = Color(0xFF5F6368)
|
||||
val Gray900 = Color(0xFF202124)
|
||||
|
||||
// System colors
|
||||
val ErrorRed = Color(0xFFB00020)
|
||||
val ErrorRedLight = Color(0xFFCF6679)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Light color scheme using Google Blue and Green from app icon
|
||||
*/
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = GoogleBlue,
|
||||
onPrimary = White,
|
||||
primaryContainer = GoogleBlueLight,
|
||||
onPrimaryContainer = Gray900,
|
||||
secondary = GoogleGreen,
|
||||
onSecondary = White,
|
||||
secondaryContainer = GoogleGreenLight,
|
||||
onSecondaryContainer = Gray900,
|
||||
tertiary = GoogleBlueDark,
|
||||
onTertiary = White,
|
||||
tertiaryContainer = GoogleBlueLight,
|
||||
onTertiaryContainer = Gray900,
|
||||
error = ErrorRed,
|
||||
onError = White,
|
||||
errorContainer = ErrorRedLight,
|
||||
onErrorContainer = Gray900,
|
||||
background = White,
|
||||
onBackground = Gray900,
|
||||
surface = White,
|
||||
onSurface = Gray900,
|
||||
surfaceVariant = Gray100,
|
||||
onSurfaceVariant = Gray700,
|
||||
outline = Gray200,
|
||||
outlineVariant = Gray100,
|
||||
scrim = Black
|
||||
)
|
||||
|
||||
/**
|
||||
* Dark color scheme using Google Blue and Green from app icon
|
||||
*/
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = GoogleBlueLight,
|
||||
onPrimary = Gray900,
|
||||
primaryContainer = GoogleBlueDark,
|
||||
onPrimaryContainer = Gray100,
|
||||
secondary = GoogleGreen,
|
||||
onSecondary = Gray900,
|
||||
secondaryContainer = GoogleGreenDark,
|
||||
onSecondaryContainer = Gray100,
|
||||
tertiary = GoogleBlue,
|
||||
onTertiary = Gray900,
|
||||
tertiaryContainer = GoogleBlueDark,
|
||||
onTertiaryContainer = Gray100,
|
||||
error = ErrorRedLight,
|
||||
onError = Gray900,
|
||||
errorContainer = ErrorRed,
|
||||
onErrorContainer = Gray100,
|
||||
background = Gray900,
|
||||
onBackground = Gray100,
|
||||
surface = Gray900,
|
||||
onSurface = Gray100,
|
||||
surfaceVariant = Gray700,
|
||||
onSurfaceVariant = Gray200,
|
||||
outline = Gray700,
|
||||
outlineVariant = Gray700,
|
||||
scrim = Black
|
||||
)
|
||||
|
||||
/**
|
||||
* SimpleLogUpload theme with Google Blue/Green branding
|
||||
*
|
||||
* @param darkTheme Whether to use dark theme (follows system by default)
|
||||
* @param content Composable content to apply theme to
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleLogUploadTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Material 3 default typography works well
|
||||
// Can be customized if specific fonts are needed
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,811 @@
|
||||
package com.mattintech.simplelogupload.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.NetworkConstants
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages chunked uploads for large files using Kotlin coroutines.
|
||||
* This class handles splitting a large file into chunks, uploading them,
|
||||
* tracking progress, and reassembling on the server.
|
||||
*
|
||||
* Key improvements over Java version:
|
||||
* - Uses Dispatchers.IO for all file operations to prevent ANR
|
||||
* - Uses coroutines instead of CompletableFuture for cleaner async code
|
||||
* - Properly handles threading to never block the main thread
|
||||
*/
|
||||
class ChunkedUploadManager(
|
||||
context: Context,
|
||||
private val file: File,
|
||||
private val maxDownloads: Int,
|
||||
private val expiryHours: Int
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = AppConstants.APP_TAG + "ChunkedUploadManager"
|
||||
|
||||
// Chunk size configuration
|
||||
private const val WIFI_CHUNK_SIZE = 10 * 1024 * 1024 // 10MB for WiFi
|
||||
private const val MOBILE_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB for mobile networks
|
||||
|
||||
// Max parallel uploads
|
||||
private const val WIFI_MAX_PARALLEL = 3
|
||||
private const val MOBILE_MAX_PARALLEL = 2
|
||||
}
|
||||
|
||||
// Context and network
|
||||
private val context: Context = context.applicationContext
|
||||
private var isWifiConnection: Boolean = false
|
||||
private var networkCallback: NetworkCallback? = null
|
||||
private val connectivityManager: ConnectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
// Upload parameters
|
||||
private val chunkSize: Int
|
||||
private val totalChunks: Int
|
||||
private val maxParallelUploads: Int
|
||||
private val totalBytes: Long = file.length()
|
||||
|
||||
// Upload state
|
||||
private var uploadId: String = UUID.randomUUID().toString()
|
||||
private val completedChunks = mutableSetOf<Int>()
|
||||
private val inProgressChunks = mutableSetOf<Int>()
|
||||
private val isPaused = AtomicBoolean(false)
|
||||
private val isCancelled = AtomicBoolean(false)
|
||||
private val activeUploads = AtomicInteger(0)
|
||||
private val uploadedBytes = AtomicLong(0)
|
||||
|
||||
// Progress tracking
|
||||
private var progressListener: UploadProgressListener? = null
|
||||
|
||||
// OkHttpClient for uploads
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
// Coroutine scope for upload operations
|
||||
private val uploadScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
// Determine initial connection type
|
||||
detectConnectionType()
|
||||
|
||||
// Configure chunk size based on connection type
|
||||
chunkSize = if (isWifiConnection) WIFI_CHUNK_SIZE else MOBILE_CHUNK_SIZE
|
||||
|
||||
// Configure max parallel uploads based on connection type and user preference
|
||||
val parallelEnabled = PreferencesUtil.isParallelUploadsEnabled(context)
|
||||
maxParallelUploads = when {
|
||||
isWifiConnection -> WIFI_MAX_PARALLEL
|
||||
parallelEnabled -> MOBILE_MAX_PARALLEL
|
||||
else -> 1
|
||||
}
|
||||
|
||||
// Calculate total chunks
|
||||
totalChunks = ceil(file.length().toDouble() / chunkSize).toInt()
|
||||
|
||||
// Setup HTTP client
|
||||
setupHttpClient()
|
||||
|
||||
Log.d(TAG, "Created chunked upload manager for file: ${file.name}")
|
||||
Log.d(TAG, "Total size: $totalBytes bytes, Chunks: $totalChunks")
|
||||
Log.d(TAG, "Connection type: ${if (isWifiConnection) "WiFi" else "Mobile"}, " +
|
||||
"Chunk size: $chunkSize bytes, " +
|
||||
"Parallel uploads: $maxParallelUploads ${if (parallelEnabled) "(enabled)" else "(disabled)"}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tracking upload progress
|
||||
*/
|
||||
interface UploadProgressListener {
|
||||
fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int)
|
||||
fun onChunkComplete(chunkIndex: Int, totalChunks: Int)
|
||||
fun onUploadComplete(result: UploadResult)
|
||||
fun onError(error: String)
|
||||
fun onPaused()
|
||||
fun onResumed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for upload progress events
|
||||
*/
|
||||
fun setProgressListener(listener: UploadProgressListener) {
|
||||
this.progressListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the upload process
|
||||
* Maintains CompletableFuture API for compatibility with existing code
|
||||
*/
|
||||
fun startUpload(): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
uploadScope.launch {
|
||||
try {
|
||||
// Reset state
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.clear()
|
||||
}
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.clear()
|
||||
}
|
||||
uploadedBytes.set(0)
|
||||
isPaused.set(false)
|
||||
isCancelled.set(false)
|
||||
|
||||
// Start network monitoring
|
||||
withContext(Dispatchers.Main) {
|
||||
registerNetworkCallback()
|
||||
}
|
||||
|
||||
// Initialize upload session
|
||||
val sessionId = initializeUploadSession()
|
||||
if (sessionId.isNullOrEmpty()) {
|
||||
future.complete(UploadResult("Failed to initialize upload session"))
|
||||
return@launch
|
||||
}
|
||||
|
||||
uploadId = sessionId
|
||||
Log.d(TAG, "Upload session initialized with ID: $uploadId")
|
||||
|
||||
// Upload all chunks
|
||||
val isComplete = uploadAllChunks()
|
||||
|
||||
if (isComplete) {
|
||||
// Finalize the upload
|
||||
val result = finalizeUpload()
|
||||
future.complete(result)
|
||||
} else {
|
||||
future.complete(UploadResult("Upload incomplete or cancelled"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during upload", e)
|
||||
future.complete(UploadResult("Upload error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an upload session with the server
|
||||
*/
|
||||
private suspend fun initializeUploadSession(): String? = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val initUrl = "$serverBaseUrl/upload/start"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("filename", file.name)
|
||||
.addFormDataPart("filesize", totalBytes.toString())
|
||||
.addFormDataPart("chunk_size", chunkSize.toString())
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart("max_downloads", maxDownloads.toString())
|
||||
.addFormDataPart("expiry_hours", expiryHours.toString())
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(initUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Initializing upload session...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to initialize upload session", e)
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val sessionId = jsonResponse.getString("upload_id")
|
||||
continuation.resume(sessionId)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse session initialization response", e)
|
||||
continuation.resume(null)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Session initialization failed with code: ${response.code}")
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all chunks to the server
|
||||
* Uses coroutines to manage parallel uploads efficiently
|
||||
*/
|
||||
private suspend fun uploadAllChunks(): Boolean = coroutineScope {
|
||||
val deferredResults = mutableListOf<Deferred<Boolean>>()
|
||||
|
||||
while (true) {
|
||||
if (isCancelled.get()) {
|
||||
return@coroutineScope false
|
||||
}
|
||||
|
||||
if (isPaused.get()) {
|
||||
delay(100)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if all chunks are complete
|
||||
synchronized(completedChunks) {
|
||||
if (completedChunks.size == totalChunks) {
|
||||
val allChunksPresent = (0 until totalChunks).all { it in completedChunks }
|
||||
if (allChunksPresent) {
|
||||
Log.d(TAG, "All chunks uploaded successfully")
|
||||
return@coroutineScope true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find next chunks to upload
|
||||
val chunksToUpload = mutableListOf<Int>()
|
||||
synchronized(completedChunks) {
|
||||
synchronized(inProgressChunks) {
|
||||
for (i in 0 until totalChunks) {
|
||||
if (activeUploads.get() >= maxParallelUploads) break
|
||||
if (i !in completedChunks && i !in inProgressChunks) {
|
||||
chunksToUpload.add(i)
|
||||
inProgressChunks.add(i)
|
||||
activeUploads.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch uploads for new chunks
|
||||
for (chunkIndex in chunksToUpload) {
|
||||
val deferred = async {
|
||||
try {
|
||||
val success = uploadChunk(chunkIndex)
|
||||
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.remove(chunkIndex)
|
||||
}
|
||||
activeUploads.decrementAndGet()
|
||||
|
||||
if (success) {
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.add(chunkIndex)
|
||||
}
|
||||
progressListener?.onChunkComplete(chunkIndex, totalChunks)
|
||||
} else if (!isCancelled.get() && !isPaused.get()) {
|
||||
// Retry failed chunks
|
||||
Log.d(TAG, "Chunk $chunkIndex failed, will retry")
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.remove(chunkIndex)
|
||||
}
|
||||
}
|
||||
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading chunk $chunkIndex", e)
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.remove(chunkIndex)
|
||||
}
|
||||
activeUploads.decrementAndGet()
|
||||
false
|
||||
}
|
||||
}
|
||||
deferredResults.add(deferred)
|
||||
}
|
||||
|
||||
// Clean up completed deferreds
|
||||
deferredResults.removeAll { it.isCompleted }
|
||||
|
||||
// Check if we're stalled
|
||||
synchronized(completedChunks) {
|
||||
synchronized(inProgressChunks) {
|
||||
if (activeUploads.get() == 0 && inProgressChunks.isEmpty() &&
|
||||
completedChunks.size < totalChunks && !isPaused.get()) {
|
||||
Log.e(TAG, "Upload stalled with ${completedChunks.size}/$totalChunks chunks completed")
|
||||
return@coroutineScope false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay before checking again
|
||||
delay(100)
|
||||
}
|
||||
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
false // This line is unreachable but needed for type inference
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single chunk to the server
|
||||
* CRITICAL: Uses Dispatchers.IO for file reading to prevent ANR
|
||||
*/
|
||||
private suspend fun uploadChunk(chunkIndex: Int): Boolean = withContext(Dispatchers.IO) {
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val chunkUrl = "$serverBaseUrl/upload/chunk"
|
||||
|
||||
try {
|
||||
// Calculate chunk boundaries
|
||||
val start = chunkIndex.toLong() * chunkSize
|
||||
val end = min(start + chunkSize, file.length())
|
||||
val currentChunkSize = (end - start).toInt()
|
||||
|
||||
Log.d(TAG, "Uploading chunk $chunkIndex ($currentChunkSize bytes)")
|
||||
|
||||
// Read chunk data on IO dispatcher - THIS FIXES THE ANR!
|
||||
val buffer = ByteArray(currentChunkSize)
|
||||
FileInputStream(file).use { inputStream ->
|
||||
inputStream.skip(start)
|
||||
val bytesRead = inputStream.read(buffer)
|
||||
if (bytesRead != currentChunkSize) {
|
||||
Log.e(TAG, "Failed to read chunk data: expected $currentChunkSize bytes, got $bytesRead")
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.addFormDataPart("chunk_index", chunkIndex.toString())
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart(
|
||||
"chunk",
|
||||
"${file.name}.part$chunkIndex",
|
||||
buffer.toRequestBody("application/octet-stream".toMediaType())
|
||||
)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(chunkUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
// Execute request (suspend until complete)
|
||||
val response = client.newCall(request).await()
|
||||
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val success = jsonResponse.optBoolean("success", false)
|
||||
val confirmedChunkIndex = jsonResponse.optInt("chunk_index", -1)
|
||||
|
||||
if (success && (confirmedChunkIndex == chunkIndex || confirmedChunkIndex == -1)) {
|
||||
// Update progress
|
||||
val newTotal = uploadedBytes.addAndGet(currentChunkSize.toLong())
|
||||
val percentComplete = (newTotal * 100 / totalBytes).toInt()
|
||||
|
||||
// Notify listener on main thread
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onProgress(newTotal, totalBytes, percentComplete)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Chunk $chunkIndex uploaded successfully. Progress: $percentComplete%")
|
||||
return@withContext true
|
||||
} else {
|
||||
Log.e(TAG, "Server reported issue with chunk $chunkIndex: $responseBody")
|
||||
return@withContext false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing chunk upload response for chunk $chunkIndex", e)
|
||||
return@withContext false
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Chunk $chunkIndex upload failed with code: ${response.code}, Body: $errorBody")
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error preparing chunk $chunkIndex", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert OkHttp Call to suspend function
|
||||
*/
|
||||
private suspend fun Call.await(): Response = suspendCoroutine { continuation ->
|
||||
enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify which chunks the server has received
|
||||
*/
|
||||
private suspend fun verifyChunksWithServer(): Set<Int> = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val verifyUrl = "$serverBaseUrl/upload/verify"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(verifyUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Verifying uploaded chunks with server...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to verify chunks with server", e)
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val missingChunks = mutableSetOf<Int>()
|
||||
|
||||
if (jsonResponse.has("missing_chunks")) {
|
||||
val missingArray = jsonResponse.getJSONArray("missing_chunks")
|
||||
for (i in 0 until missingArray.length()) {
|
||||
missingChunks.add(missingArray.getInt(i))
|
||||
}
|
||||
Log.d(TAG, "Server missing chunks: $missingChunks")
|
||||
}
|
||||
|
||||
continuation.resume(missingChunks)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing chunk verification response", e)
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Chunk verification failed with code: ${response.code}, Body: $errorBody")
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry uploading specific chunks
|
||||
*/
|
||||
private suspend fun retrySpecificChunks(chunkIndexes: Set<Int>): Boolean = coroutineScope {
|
||||
if (chunkIndexes.isEmpty()) return@coroutineScope true
|
||||
|
||||
Log.d(TAG, "Retrying specific chunks: $chunkIndexes")
|
||||
|
||||
val results = chunkIndexes.map { chunkIndex ->
|
||||
async {
|
||||
Log.d(TAG, "Retrying upload of chunk $chunkIndex")
|
||||
val success = uploadChunk(chunkIndex)
|
||||
if (success) {
|
||||
Log.d(TAG, "Successfully reuploaded chunk $chunkIndex")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to reupload chunk $chunkIndex")
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
|
||||
val allSuccessful = results.awaitAll().all { it }
|
||||
Log.d(TAG, "All chunk retries completed. ${results.count { it.await() }}/${results.size} successful")
|
||||
|
||||
return@coroutineScope allSuccessful
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the upload on the server
|
||||
*/
|
||||
private suspend fun finalizeUpload(): UploadResult {
|
||||
// Verify all chunks are uploaded
|
||||
val missingChunks = synchronized(completedChunks) {
|
||||
(0 until totalChunks).filter { it !in completedChunks }.toSet()
|
||||
}
|
||||
|
||||
if (missingChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Cannot finalize: missing chunks $missingChunks")
|
||||
return UploadResult("Cannot finalize: missing chunks $missingChunks")
|
||||
}
|
||||
|
||||
// Verify with server
|
||||
val serverMissingChunks = verifyChunksWithServer()
|
||||
if (serverMissingChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Server missing chunks: $serverMissingChunks, reuploading")
|
||||
|
||||
// Subtract bytes for missing chunks
|
||||
for (chunkIndex in serverMissingChunks) {
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.remove(chunkIndex)
|
||||
}
|
||||
val start = chunkIndex.toLong() * chunkSize
|
||||
val end = min(start + chunkSize, file.length())
|
||||
val chunkBytes = end - start
|
||||
uploadedBytes.addAndGet(-chunkBytes)
|
||||
}
|
||||
|
||||
// Retry missing chunks
|
||||
val retrySuccess = retrySpecificChunks(serverMissingChunks)
|
||||
if (!retrySuccess) {
|
||||
Log.e(TAG, "Failed to reupload missing chunks")
|
||||
return UploadResult("Failed to reupload missing chunks")
|
||||
}
|
||||
Log.d(TAG, "Missing chunks reuploaded, finalizing")
|
||||
}
|
||||
|
||||
// Perform finalize request
|
||||
return performFinalizeRequest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the finalize request to the server
|
||||
*/
|
||||
private suspend fun performFinalizeRequest(): UploadResult = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val finalizeUrl = "$serverBaseUrl/upload/complete"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.addFormDataPart("filename", file.name)
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart("max_downloads", maxDownloads.toString())
|
||||
.addFormDataPart("expiry_hours", expiryHours.toString())
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(finalizeUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Finalizing upload...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to finalize upload", e)
|
||||
continuation.resume(UploadResult("Failed to finalize upload: ${e.message}"))
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
Log.d(TAG, "Server response: $responseBody")
|
||||
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val uploadCode = jsonResponse.getString("code")
|
||||
|
||||
Log.d(TAG, "Upload finalized successfully! Code: $uploadCode")
|
||||
|
||||
val result = UploadResult(uploadCode, file.name, maxDownloads, expiryHours)
|
||||
progressListener?.onUploadComplete(result)
|
||||
|
||||
continuation.resume(result)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse server response", e)
|
||||
continuation.resume(UploadResult("Failed to parse server response"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Finalize failed with code: ${response.code}, Body: $errorBody")
|
||||
continuation.resume(UploadResult("Failed to finalize upload: ${response.code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the upload
|
||||
*/
|
||||
fun pauseUpload() {
|
||||
if (!isPaused.getAndSet(true)) {
|
||||
Log.d(TAG, "Upload paused")
|
||||
progressListener?.onPaused()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the upload
|
||||
* Maintains CompletableFuture API for compatibility
|
||||
*/
|
||||
fun resumeUpload() {
|
||||
if (isPaused.getAndSet(false)) {
|
||||
Log.d(TAG, "Upload resumed")
|
||||
progressListener?.onResumed()
|
||||
|
||||
// Launch resume in upload scope
|
||||
uploadScope.launch {
|
||||
try {
|
||||
val isComplete = uploadAllChunks()
|
||||
if (isComplete) {
|
||||
val result = finalizeUpload()
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onUploadComplete(result)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onError("Upload incomplete or cancelled")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during chunk uploading", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onError("Chunk upload error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the upload
|
||||
*/
|
||||
fun cancelUpload() {
|
||||
isCancelled.set(true)
|
||||
Log.d(TAG, "Upload cancelled")
|
||||
|
||||
// Cancel all coroutines
|
||||
uploadScope.cancel()
|
||||
|
||||
// Unregister network callback
|
||||
unregisterNetworkCallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup HTTP client with timeouts
|
||||
*/
|
||||
private fun setupHttpClient() {
|
||||
val connectionTimeout = if (isWifiConnection)
|
||||
NetworkConstants.CONNECTION_TIMEOUT
|
||||
else
|
||||
NetworkConstants.CONNECTION_TIMEOUT / 2
|
||||
|
||||
val writeTimeout = if (isWifiConnection)
|
||||
NetworkConstants.WRITE_TIMEOUT
|
||||
else
|
||||
NetworkConstants.WRITE_TIMEOUT / 2
|
||||
|
||||
val readTimeout = if (isWifiConnection)
|
||||
NetworkConstants.READ_TIMEOUT
|
||||
else
|
||||
NetworkConstants.READ_TIMEOUT / 2
|
||||
|
||||
client = OkHttpClient.Builder()
|
||||
.connectTimeout(connectionTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.writeTimeout(writeTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current connection type (WiFi or Mobile)
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun detectConnectionType() {
|
||||
val activeNetwork = connectivityManager.activeNetworkInfo
|
||||
isWifiConnection = activeNetwork != null &&
|
||||
activeNetwork.type == ConnectivityManager.TYPE_WIFI
|
||||
|
||||
Log.d(TAG, "Connection type detected: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Register network callback to monitor connection changes
|
||||
*/
|
||||
private fun registerNetworkCallback() {
|
||||
if (networkCallback == null) {
|
||||
networkCallback = NetworkCallback()
|
||||
}
|
||||
|
||||
val builder = NetworkRequest.Builder()
|
||||
connectivityManager.registerNetworkCallback(builder.build(), networkCallback!!)
|
||||
|
||||
Log.d(TAG, "Network callback registered")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister network callback
|
||||
*/
|
||||
private fun unregisterNetworkCallback() {
|
||||
networkCallback?.let {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(it)
|
||||
Log.d(TAG, "Network callback unregistered")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network callback for handling connection changes
|
||||
*/
|
||||
private inner class NetworkCallback : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
val wasWifi = isWifiConnection
|
||||
detectConnectionType()
|
||||
|
||||
Log.d(TAG, "Network available: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
|
||||
// Resume if paused due to network loss
|
||||
if (isPaused.get()) {
|
||||
Log.d(TAG, "Network restored, resuming upload")
|
||||
resumeUpload()
|
||||
}
|
||||
|
||||
// Adjust settings if connection type changed
|
||||
if (wasWifi != isWifiConnection) {
|
||||
Log.d(TAG, "Connection type changed, adjusting settings")
|
||||
setupHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
Log.d(TAG, "Network lost")
|
||||
pauseUpload()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
super.onCapabilitiesChanged(network, capabilities)
|
||||
|
||||
val newIsWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|
||||
if (newIsWifi != isWifiConnection) {
|
||||
isWifiConnection = newIsWifi
|
||||
Log.d(TAG, "Connection capabilities changed to: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
setupHttpClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.mattintech.simplelogupload.upload
|
||||
|
||||
/**
|
||||
* Callback interface for tracking upload progress
|
||||
*/
|
||||
interface UploadProgressCallback {
|
||||
/**
|
||||
* Called when upload progress changes
|
||||
*
|
||||
* @param bytesUploaded Number of bytes uploaded so far
|
||||
* @param totalBytes Total number of bytes to upload
|
||||
* @param percentComplete Percentage complete (0-100)
|
||||
*/
|
||||
fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int)
|
||||
|
||||
/**
|
||||
* Called when a chunk upload completes (for chunked uploads)
|
||||
*
|
||||
* @param chunkIndex Index of the completed chunk
|
||||
* @param totalChunks Total number of chunks
|
||||
*/
|
||||
fun onChunkComplete(chunkIndex: Int, totalChunks: Int)
|
||||
|
||||
/**
|
||||
* Called for general status updates
|
||||
*
|
||||
* @param status Status message
|
||||
*/
|
||||
fun onStatusUpdate(status: String)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.mattintech.simplelogupload.upload;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.util.Log;
|
||||
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Helper class to determine the best upload strategy based on file size and network conditions
|
||||
*/
|
||||
public class UploadStrategySelector {
|
||||
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
|
||||
|
||||
// Threshold for using chunked uploads - matches chunk sizes
|
||||
// Only use chunked upload if file is larger than chunk size
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 10 * 1024 * 1024; // 10MB for WiFi (matches chunk size)
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 4 * 1024 * 1024; // 4MB for mobile (matches chunk size)
|
||||
|
||||
/**
|
||||
* Check if chunked uploads should be used for the given file
|
||||
*
|
||||
* @param context Application context
|
||||
* @param file The file to upload
|
||||
* @return True if chunked upload should be used, false otherwise
|
||||
*/
|
||||
public static boolean shouldUseChunkedUpload(Context context, File file) {
|
||||
if (file == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long fileSize = file.length();
|
||||
boolean isWifiConnection = isWifiConnection(context);
|
||||
long threshold = isWifiConnection ? CHUNKED_UPLOAD_THRESHOLD_WIFI : CHUNKED_UPLOAD_THRESHOLD_MOBILE;
|
||||
|
||||
boolean useChunked = fileSize > threshold;
|
||||
Log.d(TAG, "File size: " + fileSize + " bytes, Connection: " + (isWifiConnection ? "WiFi" : "Mobile") +
|
||||
", Using chunked upload: " + useChunked);
|
||||
|
||||
return useChunked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the device is currently connected to WiFi
|
||||
*
|
||||
* @param context Application context
|
||||
* @return True if connected to WiFi, false otherwise
|
||||
*/
|
||||
public static boolean isWifiConnection(Context context) {
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
|
||||
return activeNetwork != null && activeNetwork.getType() == ConnectivityManager.TYPE_WIFI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the network type name for logging
|
||||
*
|
||||
* @param context Application context
|
||||
* @return String representing the network type ("WiFi", "Mobile", or "Unknown")
|
||||
*/
|
||||
public static String getNetworkTypeName(Context context) {
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo info = cm.getActiveNetworkInfo();
|
||||
|
||||
if (info == null || !info.isConnected()) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
if (info.getType() == ConnectivityManager.TYPE_WIFI) {
|
||||
return "WiFi";
|
||||
}
|
||||
|
||||
if (info.getType() == ConnectivityManager.TYPE_MOBILE) {
|
||||
String subType = "";
|
||||
switch (info.getSubtype()) {
|
||||
case 13: // LTE
|
||||
subType = "LTE";
|
||||
break;
|
||||
case 19: // 5G
|
||||
subType = "5G";
|
||||
break;
|
||||
case 20: // 5G+
|
||||
subType = "5G+";
|
||||
break;
|
||||
default:
|
||||
subType = info.getSubtypeName();
|
||||
}
|
||||
return "Mobile (" + subType + ")";
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.mattintech.simplelogupload.util
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Utility object for device-specific feature detection
|
||||
*/
|
||||
object DeviceUtil {
|
||||
/**
|
||||
* Check if the current device is a Samsung device
|
||||
*
|
||||
* @return true if device manufacturer is Samsung, false otherwise
|
||||
*/
|
||||
fun isSamsungDevice(): Boolean {
|
||||
return Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dumpstate features should be shown on this device
|
||||
* Currently only Samsung devices have dumpstate file support
|
||||
*
|
||||
* @return true if dumpstate features should be visible
|
||||
*/
|
||||
fun shouldShowDumpstateFeatures(): Boolean {
|
||||
return isSamsungDevice()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device manufacturer name
|
||||
*
|
||||
* @return Device manufacturer (e.g., "Samsung", "Google", "Xiaomi")
|
||||
*/
|
||||
fun getManufacturer(): String {
|
||||
return Build.MANUFACTURER
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device model name
|
||||
*
|
||||
* @return Device model (e.g., "SM-G991U", "Pixel 7")
|
||||
*/
|
||||
fun getModel(): String {
|
||||
return Build.MODEL
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class PermissionUtil {
|
||||
|
||||
public static final int REQUEST_MANAGE_EXTERNAL_STORAGE = 101;
|
||||
public static final int REQUEST_NOTIFICATION_PERMISSION = 102;
|
||||
public static final int REQUEST_CAMERA_PERMISSION = 103;
|
||||
|
||||
/**
|
||||
* Checks if the app has notification permission
|
||||
@@ -93,4 +94,31 @@ public class PermissionUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the app has camera permission
|
||||
*
|
||||
* @param context The context
|
||||
* @return true if the app has camera permission, false otherwise
|
||||
*/
|
||||
public static boolean hasCameraPermission(Context context) {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests camera permission
|
||||
*
|
||||
* @param activity The activity
|
||||
*/
|
||||
public static void requestCameraPermission(Activity activity) {
|
||||
if (!hasCameraPermission(activity)) {
|
||||
Log.d(TAG, "Requesting camera permission");
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
new String[]{Manifest.permission.CAMERA},
|
||||
REQUEST_CAMERA_PERMISSION
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,24 @@ public class PreferencesUtil {
|
||||
public static final String KEY_SERVER_PORT = "server_port";
|
||||
public static final String KEY_CLIENT_KEY = "client_key";
|
||||
public static final String KEY_SETUP_COMPLETED = "setup_completed";
|
||||
public static final String KEY_ONBOARDING_COMPLETED = "onboarding_completed";
|
||||
public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads";
|
||||
public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours";
|
||||
public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload";
|
||||
public static final String KEY_UPLOAD_IN_PROGRESS = "upload_in_progress";
|
||||
public static final String KEY_UPLOAD_FILE_NAME = "upload_file_name";
|
||||
public static final String KEY_ENABLE_PARALLEL_UPLOADS = "enable_parallel_uploads";
|
||||
|
||||
// Default values
|
||||
private static final String DEFAULT_SERVER_ADDRESS = "";
|
||||
private static final int DEFAULT_SERVER_PORT = 0;
|
||||
private static final String DEFAULT_CLIENT_KEY = "";
|
||||
private static final boolean DEFAULT_SETUP_COMPLETED = false;
|
||||
private static final boolean DEFAULT_ONBOARDING_COMPLETED = false;
|
||||
private static final int DEFAULT_MAX_DOWNLOADS = 5;
|
||||
private static final int DEFAULT_EXPIRY_HOURS = 24;
|
||||
private static final boolean DEFAULT_ASK_BEFORE_UPLOAD = true;
|
||||
private static final boolean DEFAULT_ENABLE_PARALLEL_UPLOADS = false;
|
||||
|
||||
/**
|
||||
* Get the shared preferences instance
|
||||
@@ -126,6 +138,11 @@ public class PreferencesUtil {
|
||||
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
|
||||
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
|
||||
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED);
|
||||
editor.putBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||
editor.putInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||
editor.putInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||
editor.putBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||
editor.putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
@@ -149,6 +166,86 @@ public class PreferencesUtil {
|
||||
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if onboarding has been completed, false otherwise
|
||||
*/
|
||||
public static boolean isOnboardingCompleted(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the onboarding completed status
|
||||
*
|
||||
* @param context The application context
|
||||
* @param completed True if onboarding has been completed, false otherwise
|
||||
*/
|
||||
public static void setOnboardingCompleted(Context context, boolean completed) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default max downloads from preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The default max downloads
|
||||
*/
|
||||
public static int getDefaultMaxDownloads(Context context) {
|
||||
return getPreferences(context).getInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default max downloads in preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @param maxDownloads The default max downloads to save
|
||||
*/
|
||||
public static void setDefaultMaxDownloads(Context context, int maxDownloads) {
|
||||
getPreferences(context).edit().putInt(KEY_DEFAULT_MAX_DOWNLOADS, maxDownloads).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default expiry hours from preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The default expiry hours
|
||||
*/
|
||||
public static int getDefaultExpiryHours(Context context) {
|
||||
return getPreferences(context).getInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default expiry hours in preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @param expiryHours The default expiry hours to save
|
||||
*/
|
||||
public static void setDefaultExpiryHours(Context context, int expiryHours) {
|
||||
getPreferences(context).edit().putInt(KEY_DEFAULT_EXPIRY_HOURS, expiryHours).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ask before upload preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if should ask before upload, false otherwise
|
||||
*/
|
||||
public static boolean getAskBeforeUpload(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ask before upload preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @param askBeforeUpload True if should ask before upload, false otherwise
|
||||
*/
|
||||
public static void setAskBeforeUpload(Context context, boolean askBeforeUpload) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ASK_BEFORE_UPLOAD, askBeforeUpload).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required server settings are configured
|
||||
*
|
||||
@@ -163,4 +260,62 @@ public class PreferencesUtil {
|
||||
|
||||
return !address.isEmpty() && port > 0 && !clientKey.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an upload is currently in progress
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if an upload is in progress, false otherwise
|
||||
*/
|
||||
public static boolean isUploadInProgress(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_UPLOAD_IN_PROGRESS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the upload in progress status
|
||||
*
|
||||
* @param context The application context
|
||||
* @param inProgress True if upload is in progress, false otherwise
|
||||
* @param fileName The name of the file being uploaded (optional)
|
||||
*/
|
||||
public static void setUploadInProgress(Context context, boolean inProgress, String fileName) {
|
||||
SharedPreferences.Editor editor = getPreferences(context).edit();
|
||||
editor.putBoolean(KEY_UPLOAD_IN_PROGRESS, inProgress);
|
||||
if (inProgress && fileName != null) {
|
||||
editor.putString(KEY_UPLOAD_FILE_NAME, fileName);
|
||||
} else if (!inProgress) {
|
||||
editor.remove(KEY_UPLOAD_FILE_NAME);
|
||||
}
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file currently being uploaded
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The file name, or null if no upload is in progress
|
||||
*/
|
||||
public static String getUploadFileName(Context context) {
|
||||
return getPreferences(context).getString(KEY_UPLOAD_FILE_NAME, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parallel uploads enabled preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if parallel uploads are enabled, false otherwise
|
||||
*/
|
||||
public static boolean isParallelUploadsEnabled(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the parallel uploads enabled preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @param enabled True to enable parallel uploads, false otherwise
|
||||
*/
|
||||
public static void setParallelUploadsEnabled(Context context, boolean enabled) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, enabled).apply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.mattintech.simplelogupload.util;
|
||||
|
||||
import com.journeyapps.barcodescanner.ScanOptions;
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig;
|
||||
|
||||
/**
|
||||
* Utility class for QR code scanning
|
||||
*/
|
||||
public class QrScannerUtil {
|
||||
|
||||
/**
|
||||
* Callback interface for QR scan results
|
||||
*/
|
||||
public interface QrScanCallback {
|
||||
void onQrScanned(QrServerConfig config);
|
||||
void onQrScanCancelled();
|
||||
void onQrScanError(String errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create scan options for QR code scanning
|
||||
*
|
||||
* @return ScanOptions configured for QR codes
|
||||
*/
|
||||
public static ScanOptions createScanOptions() {
|
||||
ScanOptions options = new ScanOptions();
|
||||
options.setDesiredBarcodeFormats(ScanOptions.QR_CODE);
|
||||
options.setPrompt("Scan server configuration QR code");
|
||||
options.setBeepEnabled(true);
|
||||
options.setBarcodeImageEnabled(false);
|
||||
options.setOrientationLocked(true);
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process scan result
|
||||
*
|
||||
* @param contents The scanned QR code contents
|
||||
* @param callback The callback to handle the result
|
||||
*/
|
||||
public static void processScanResult(String contents, QrScanCallback callback) {
|
||||
if (contents == null) {
|
||||
callback.onQrScanCancelled();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
QrServerConfig config = QrServerConfig.fromQrData(contents);
|
||||
callback.onQrScanned(config);
|
||||
} catch (QrServerConfig.QrConfigException e) {
|
||||
callback.onQrScanError(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
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.List;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses multiple files into a single zip archive
|
||||
*
|
||||
* @param sourceFiles List of files to compress
|
||||
* @param outputDirectory Directory to store the zip file
|
||||
* @param zipFileName Name of the zip file to create
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
public static File zipFiles(List<File> sourceFiles, File outputDirectory, String zipFileName,
|
||||
ZipProgressListener listener) throws IOException {
|
||||
if (sourceFiles == null || sourceFiles.isEmpty()) {
|
||||
String msg = "No source files provided for zipping";
|
||||
Log.e(TAG, msg);
|
||||
throw new IOException(msg);
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if (!outputDirectory.exists()) {
|
||||
if (!outputDirectory.mkdirs()) {
|
||||
throw new IOException("Could not create output directory: " + outputDirectory.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the zip extension
|
||||
if (!zipFileName.toLowerCase().endsWith(FileConstants.ZIP_EXTENSION)) {
|
||||
zipFileName += FileConstants.ZIP_EXTENSION;
|
||||
}
|
||||
|
||||
// Create a zip file in the output directory
|
||||
File zipFile = new File(outputDirectory, zipFileName);
|
||||
|
||||
Log.d(TAG, "Creating multi-file zip: " + zipFile.getAbsolutePath() + " with " + sourceFiles.size() + " files");
|
||||
|
||||
// Calculate total size for progress reporting
|
||||
long totalBytes = 0;
|
||||
for (File file : sourceFiles) {
|
||||
if (file.exists()) {
|
||||
totalBytes += file.length();
|
||||
}
|
||||
}
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(zipFile);
|
||||
BufferedOutputStream bos = new BufferedOutputStream(fos);
|
||||
ZipOutputStream zos = new ZipOutputStream(bos)) {
|
||||
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long bytesProcessed = 0;
|
||||
|
||||
for (File file : sourceFiles) {
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
Log.w(TAG, "Skipping non-existent or non-file: " + file.getAbsolutePath());
|
||||
continue;
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(file);
|
||||
BufferedInputStream bis = new BufferedInputStream(fis)) {
|
||||
|
||||
ZipEntry zipEntry = new ZipEntry(file.getName());
|
||||
zipEntry.setSize(file.length());
|
||||
zos.putNextEntry(zipEntry);
|
||||
|
||||
int len;
|
||||
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 multi-file zip: " + zipFile.getName());
|
||||
return zipFile;
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error creating multi-file zip", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal file
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.mattintech.simplelogupload.util
|
||||
|
||||
import android.util.Log
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.FileConstants
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Utility class for zipping files using Kotlin coroutines
|
||||
* CRITICAL: All file I/O operations run on Dispatchers.IO to prevent ANR
|
||||
*/
|
||||
object ZipUtil {
|
||||
private const val TAG = AppConstants.APP_TAG + "ZipUtil"
|
||||
private const val BUFFER_SIZE = 8192 // 8KB buffer
|
||||
|
||||
/**
|
||||
* Interface for tracking zip progress
|
||||
*/
|
||||
interface ZipProgressListener {
|
||||
fun onProgress(bytesProcessed: Long, totalBytes: Long)
|
||||
fun onComplete(zipFile: File)
|
||||
fun onError(e: Exception)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a file into a zip archive
|
||||
* Runs on Dispatchers.IO to prevent ANR
|
||||
* Checks for cancellation during compression
|
||||
*
|
||||
* @param sourceFile The file to compress
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
@JvmStatic
|
||||
suspend fun zipFile(sourceFile: File, listener: ZipProgressListener? = null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!sourceFile.exists()) {
|
||||
val msg = "Source file does not exist: ${sourceFile.absolutePath}"
|
||||
Log.e(TAG, msg)
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(sourceFile)) {
|
||||
Log.d(TAG, "File is already a zip file: ${sourceFile.name}")
|
||||
listener?.onComplete(sourceFile)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Create a zip file in the same directory
|
||||
val zipFileName = sourceFile.name + FileConstants.ZIP_EXTENSION
|
||||
val zipFile = File(sourceFile.parentFile, zipFileName)
|
||||
|
||||
Log.d(TAG, "Creating zip file: ${zipFile.absolutePath}")
|
||||
|
||||
try {
|
||||
FileOutputStream(zipFile).use { fos ->
|
||||
BufferedOutputStream(fos).use { bos ->
|
||||
ZipOutputStream(bos).use { zos ->
|
||||
FileInputStream(sourceFile).use { fis ->
|
||||
BufferedInputStream(fis).use { bis ->
|
||||
val zipEntry = ZipEntry(sourceFile.name)
|
||||
zipEntry.size = sourceFile.length()
|
||||
zos.putNextEntry(zipEntry)
|
||||
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytesProcessed = 0L
|
||||
val totalBytes = sourceFile.length()
|
||||
var len: Int
|
||||
var lastProgressUpdate = 0L
|
||||
|
||||
while (bis.read(buffer).also { len = it } > 0) {
|
||||
// Check for cancellation
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Zip operation cancelled by user")
|
||||
throw CancellationException("Zip operation cancelled")
|
||||
}
|
||||
|
||||
zos.write(buffer, 0, len)
|
||||
bytesProcessed += len
|
||||
|
||||
// Update progress every 100KB to avoid overwhelming UI
|
||||
if (bytesProcessed - lastProgressUpdate >= 100_000 || bytesProcessed == totalBytes) {
|
||||
lastProgressUpdate = bytesProcessed
|
||||
Log.d(TAG, "Zip progress: $bytesProcessed / $totalBytes bytes (${(bytesProcessed * 100 / totalBytes)}%)")
|
||||
listener?.onProgress(bytesProcessed, totalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onComplete(zipFile)
|
||||
Log.d(TAG, "Successfully created zip file: ${zipFile.name}")
|
||||
} catch (e: CancellationException) {
|
||||
// Clean up the partial zip file
|
||||
if (zipFile.exists()) {
|
||||
zipFile.delete()
|
||||
Log.d(TAG, "Deleted partial zip file after cancellation")
|
||||
}
|
||||
// Re-throw to cancel the coroutine properly
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating zip file", e)
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
val deleted = zipFile.delete()
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Don't treat cancellation as an error - just re-throw it
|
||||
Log.d(TAG, "Zip cancelled - cleaning up")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses multiple files into a single zip archive
|
||||
* Runs on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param sourceFiles List of files to compress
|
||||
* @param outputDirectory Directory to store the zip file
|
||||
* @param zipFileName Name of the zip file to create
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
@JvmStatic
|
||||
suspend fun zipFiles(
|
||||
sourceFiles: List<File>,
|
||||
outputDirectory: File,
|
||||
zipFileName: String,
|
||||
listener: ZipProgressListener? = null
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
if (sourceFiles.isEmpty()) {
|
||||
val msg = "No source files provided for zipping"
|
||||
Log.e(TAG, msg)
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if (!outputDirectory.exists()) {
|
||||
if (!outputDirectory.mkdirs()) {
|
||||
throw IOException("Could not create output directory: ${outputDirectory.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the zip extension
|
||||
val finalZipFileName = if (zipFileName.lowercase().endsWith(FileConstants.ZIP_EXTENSION)) {
|
||||
zipFileName
|
||||
} else {
|
||||
zipFileName + FileConstants.ZIP_EXTENSION
|
||||
}
|
||||
|
||||
// Create a zip file in the output directory
|
||||
val zipFile = File(outputDirectory, finalZipFileName)
|
||||
|
||||
Log.d(TAG, "Creating multi-file zip: ${zipFile.absolutePath} with ${sourceFiles.size} files")
|
||||
|
||||
// Calculate total size for progress reporting
|
||||
val totalBytes = sourceFiles.filter { it.exists() }.sumOf { it.length() }
|
||||
|
||||
try {
|
||||
FileOutputStream(zipFile).use { fos ->
|
||||
BufferedOutputStream(fos).use { bos ->
|
||||
ZipOutputStream(bos).use { zos ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytesProcessed = 0L
|
||||
|
||||
for (file in sourceFiles) {
|
||||
if (!file.exists() || !file.isFile) {
|
||||
Log.w(TAG, "Skipping non-existent or non-file: ${file.absolutePath}")
|
||||
continue
|
||||
}
|
||||
|
||||
FileInputStream(file).use { fis ->
|
||||
BufferedInputStream(fis).use { bis ->
|
||||
val zipEntry = ZipEntry(file.name)
|
||||
zipEntry.size = file.length()
|
||||
zos.putNextEntry(zipEntry)
|
||||
|
||||
var len: Int
|
||||
while (bis.read(buffer).also { len = it } > 0) {
|
||||
zos.write(buffer, 0, len)
|
||||
bytesProcessed += len
|
||||
|
||||
listener?.onProgress(bytesProcessed, totalBytes)
|
||||
}
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onComplete(zipFile)
|
||||
Log.d(TAG, "Successfully created multi-file zip: ${zipFile.name}")
|
||||
|
||||
return@withContext zipFile
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating multi-file zip", e)
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
val deleted = zipFile.delete()
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.app.Dialog;
|
||||
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 androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.adapter.DumpstateFileAdapter;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dialog for selecting a dumpstate file from multiple options
|
||||
*/
|
||||
public class DumpstateSelectionDialog extends Dialog {
|
||||
|
||||
private final List<File> dumpstateFiles;
|
||||
private final OnDumpstateSelectedListener listener;
|
||||
|
||||
private TextView titleText;
|
||||
private RecyclerView recyclerView;
|
||||
private TextView emptyView;
|
||||
private Button selectButton;
|
||||
private Button cancelButton;
|
||||
|
||||
private DumpstateFileAdapter adapter;
|
||||
|
||||
/**
|
||||
* Interface for handling dumpstate selection
|
||||
*/
|
||||
public interface OnDumpstateSelectedListener {
|
||||
void onDumpstateSelected(File dumpstateFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param dumpstateFiles List of dumpstate files
|
||||
* @param listener Listener for selection events
|
||||
*/
|
||||
public DumpstateSelectionDialog(@NonNull Context context,
|
||||
File[] dumpstateFiles,
|
||||
OnDumpstateSelectedListener listener) {
|
||||
super(context);
|
||||
this.dumpstateFiles = Arrays.asList(dumpstateFiles);
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.dialog_dumpstate_selection);
|
||||
|
||||
// Initialize views
|
||||
titleText = findViewById(R.id.titleText);
|
||||
recyclerView = findViewById(R.id.dumpstateRecyclerView);
|
||||
emptyView = findViewById(R.id.emptyView);
|
||||
selectButton = findViewById(R.id.selectButton);
|
||||
cancelButton = findViewById(R.id.cancelButton);
|
||||
|
||||
// Update title with file count
|
||||
titleText.setText(getContext().getString(R.string.dumpstate_selection_subtitle, dumpstateFiles.size()));
|
||||
|
||||
// Set up RecyclerView
|
||||
setupRecyclerView();
|
||||
|
||||
// Set up buttons
|
||||
cancelButton.setOnClickListener(v -> dismiss());
|
||||
selectButton.setOnClickListener(v -> {
|
||||
File selectedFile = adapter.getSelectedFile();
|
||||
if (selectedFile != null && listener != null) {
|
||||
listener.onDumpstateSelected(selectedFile);
|
||||
dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
// Show empty view if no files
|
||||
if (dumpstateFiles.isEmpty()) {
|
||||
recyclerView.setVisibility(View.GONE);
|
||||
emptyView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
recyclerView.setVisibility(View.VISIBLE);
|
||||
emptyView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the RecyclerView with the adapter
|
||||
*/
|
||||
private void setupRecyclerView() {
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
adapter = new DumpstateFileAdapter(getContext(), dumpstateFiles);
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
// Listen for item selection to enable/disable select button
|
||||
adapter.setOnItemSelectedListener(file -> {
|
||||
selectButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.cardview.widget.CardView;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
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.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.adapter.DumpstateAdapter;
|
||||
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 com.mattintech.simplelogupload.util.PreferencesUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
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 dumpstateStatusText;
|
||||
private TextView selectedFileText;
|
||||
private Button selectFileButton;
|
||||
private Button uploadDumpstateButton;
|
||||
private Button uploadSelectedFileButton;
|
||||
private ProgressBar progressBar;
|
||||
private CardView selectedFileCard;
|
||||
|
||||
// Dumpstate multi-select components
|
||||
private RecyclerView dumpstateRecyclerView;
|
||||
private Button toggleDumpstateListButton;
|
||||
private Button selectAllButton;
|
||||
private Button clearSelectionsButton;
|
||||
private LinearLayout multiSelectButtonsLayout;
|
||||
private DumpstateAdapter dumpstateAdapter;
|
||||
|
||||
private File selectedFile;
|
||||
private File dumpstateFile;
|
||||
|
||||
private UploadController uploadController;
|
||||
|
||||
// BroadcastReceiver to listen for upload results
|
||||
private BroadcastReceiver uploadReceiver;
|
||||
|
||||
// Activity result launcher for file picking
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
|
||||
// Flag to track if dumpstate list is expanded
|
||||
private boolean isDumpstateListExpanded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Check if setup is required
|
||||
if (!PreferencesUtil.isSetupCompleted(this) || !PreferencesUtil.areServerSettingsConfigured(this)) {
|
||||
// Navigate to welcome activity
|
||||
Intent intent = new Intent(this, WelcomeActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set content view
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
uploadController = new UploadController(this);
|
||||
|
||||
// Initialize UI elements
|
||||
dumpstateStatusText = findViewById(R.id.dumpstateStatusText);
|
||||
selectedFileText = findViewById(R.id.selectedFileText);
|
||||
selectFileButton = findViewById(R.id.selectFileButton);
|
||||
uploadDumpstateButton = findViewById(R.id.uploadDumpstateButton);
|
||||
uploadSelectedFileButton = findViewById(R.id.uploadSelectedFileButton);
|
||||
progressBar = findViewById(R.id.progressBar);
|
||||
selectedFileCard = findViewById(R.id.selectedFileCard);
|
||||
|
||||
// Initialize multi-select UI elements
|
||||
dumpstateRecyclerView = findViewById(R.id.dumpstateRecyclerView);
|
||||
toggleDumpstateListButton = findViewById(R.id.toggleDumpstateListButton);
|
||||
selectAllButton = findViewById(R.id.selectAllButton);
|
||||
clearSelectionsButton = findViewById(R.id.clearSelectionsButton);
|
||||
multiSelectButtonsLayout = findViewById(R.id.multiSelectButtonsLayout);
|
||||
|
||||
// Set up RecyclerView
|
||||
dumpstateRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
|
||||
// Set up button click listeners
|
||||
selectFileButton.setOnClickListener(v -> openFilePicker());
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadDumpstate());
|
||||
uploadSelectedFileButton.setOnClickListener(v -> uploadSelectedFile());
|
||||
toggleDumpstateListButton.setOnClickListener(v -> toggleDumpstateList());
|
||||
selectAllButton.setOnClickListener(v -> selectAllDumpstates());
|
||||
clearSelectionsButton.setOnClickListener(v -> clearDumpstateSelections());
|
||||
|
||||
// Initialize file picker launcher
|
||||
filePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> {
|
||||
if (uri != null) {
|
||||
handleFileSelection(uri);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for necessary permissions
|
||||
checkAndRequestPermissions();
|
||||
|
||||
// Initialize broadcast receiver
|
||||
setupBroadcastReceiver();
|
||||
|
||||
// Handle intent (e.g., from share)
|
||||
handleIntent(getIntent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the dumpstate list
|
||||
*/
|
||||
private void toggleDumpstateList() {
|
||||
isDumpstateListExpanded = !isDumpstateListExpanded;
|
||||
|
||||
if (isDumpstateListExpanded) {
|
||||
// Show the list
|
||||
loadDumpstateFiles();
|
||||
dumpstateRecyclerView.setVisibility(View.VISIBLE);
|
||||
multiSelectButtonsLayout.setVisibility(View.VISIBLE);
|
||||
toggleDumpstateListButton.setText(R.string.hide_dumpstates);
|
||||
|
||||
// Change the upload button to handle multiple files
|
||||
updateUploadButtonForMultiSelect();
|
||||
} else {
|
||||
// Hide the list
|
||||
dumpstateRecyclerView.setVisibility(View.GONE);
|
||||
multiSelectButtonsLayout.setVisibility(View.GONE);
|
||||
toggleDumpstateListButton.setText(R.string.show_dumpstates);
|
||||
|
||||
// Restore normal upload button
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadDumpstate());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dumpstate files into the RecyclerView
|
||||
*/
|
||||
private void loadDumpstateFiles() {
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
List<File> fileList = new ArrayList<>(Arrays.asList(dumpstateFiles));
|
||||
|
||||
if (dumpstateAdapter == null) {
|
||||
dumpstateAdapter = new DumpstateAdapter(this, fileList);
|
||||
dumpstateAdapter.setOnSelectionChangedListener(this::onDumpstateSelectionChanged);
|
||||
dumpstateRecyclerView.setAdapter(dumpstateAdapter);
|
||||
} else {
|
||||
// Just update the adapter with new files
|
||||
dumpstateAdapter = new DumpstateAdapter(this, fileList);
|
||||
dumpstateAdapter.setOnSelectionChangedListener(this::onDumpstateSelectionChanged);
|
||||
dumpstateRecyclerView.setAdapter(dumpstateAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection changes in the dumpstate list
|
||||
*
|
||||
* @param selectedCount The number of selected items
|
||||
*/
|
||||
private void onDumpstateSelectionChanged(int selectedCount) {
|
||||
// Update the upload button text
|
||||
if (selectedCount > 0) {
|
||||
uploadDumpstateButton.setText(getString(R.string.upload_multiple_dumpstates, selectedCount));
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
} else {
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the upload button to handle multiple files
|
||||
*/
|
||||
private void updateUploadButtonForMultiSelect() {
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadSelectedDumpstates());
|
||||
|
||||
// Initially disable until files are selected
|
||||
uploadDumpstateButton.setEnabled(dumpstateAdapter != null && dumpstateAdapter.getSelectedCount() > 0);
|
||||
|
||||
// Update button text if there are already selections
|
||||
if (dumpstateAdapter != null && dumpstateAdapter.getSelectedCount() > 0) {
|
||||
uploadDumpstateButton.setText(getString(R.string.upload_multiple_dumpstates,
|
||||
dumpstateAdapter.getSelectedCount()));
|
||||
} else {
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all dumpstate files
|
||||
*/
|
||||
private void selectAllDumpstates() {
|
||||
if (dumpstateAdapter != null) {
|
||||
dumpstateAdapter.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dumpstate selections
|
||||
*/
|
||||
private void clearDumpstateSelections() {
|
||||
if (dumpstateAdapter != null) {
|
||||
dumpstateAdapter.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload selected dumpstate files
|
||||
*/
|
||||
private void uploadSelectedDumpstates() {
|
||||
if (dumpstateAdapter == null || dumpstateAdapter.getSelectedCount() == 0) {
|
||||
Toast.makeText(this, R.string.no_dumpstates_selected, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
List<File> selectedFiles = dumpstateAdapter.getSelectedFiles();
|
||||
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
|
||||
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
|
||||
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);
|
||||
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
|
||||
// Process the shared URI
|
||||
ShareIntentHandler.processSharedUri(this, sharedUri)
|
||||
.thenAccept(file -> {
|
||||
// Save the file for upload
|
||||
updateSelectedFile(file);
|
||||
runOnUiThread(() -> {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
Log.e(TAG, "Error processing shared file", ex);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.file_error, ex.getMessage()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file picker
|
||||
*/
|
||||
private void openFilePicker() {
|
||||
filePickerLauncher.launch("*/*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection from file picker
|
||||
*
|
||||
* @param uri The URI of the selected file
|
||||
*/
|
||||
private void handleFileSelection(Uri uri) {
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
|
||||
// Process the URI
|
||||
ShareIntentHandler.processSharedUri(this, uri)
|
||||
.thenAccept(file -> {
|
||||
// Save the file for upload
|
||||
updateSelectedFile(file);
|
||||
runOnUiThread(() -> {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
Log.e(TAG, "Error processing selected file", ex);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.file_error, ex.getMessage()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the selected file
|
||||
*/
|
||||
private void uploadSelectedFile() {
|
||||
if (selectedFile == null || !selectedFile.exists()) {
|
||||
Toast.makeText(this, R.string.no_file_selected, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show upload parameters dialog
|
||||
showUploadParamsDialog(selectedFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the dumpstate file
|
||||
*/
|
||||
private void uploadDumpstate() {
|
||||
// Get all dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length == 0) {
|
||||
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file, use it directly
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
showUploadParamsDialog(dumpstateFile);
|
||||
} else {
|
||||
// Multiple dumpstate files are available, suggest expanding the list
|
||||
if (!isDumpstateListExpanded) {
|
||||
toggleDumpstateList();
|
||||
} else {
|
||||
// If list is already expanded, use the first file
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
showUploadParamsDialog(dumpstateFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, int maxDownloads, int expiryHours) {
|
||||
if (file == null || !file.exists()) {
|
||||
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
|
||||
|
||||
// Start the upload service with parameters
|
||||
startUploadService(file, maxDownloads, expiryHours);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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
|
||||
*
|
||||
* @param file The selected file
|
||||
*/
|
||||
private void updateSelectedFile(File file) {
|
||||
selectedFile = file;
|
||||
runOnUiThread(() -> {
|
||||
selectedFileCard.setVisibility(View.VISIBLE);
|
||||
selectedFileText.setText(getString(R.string.selected_file, file.getName()));
|
||||
uploadSelectedFileButton.setVisibility(View.VISIBLE);
|
||||
uploadSelectedFileButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
uploadSelectedFileButton.setEnabled(true);
|
||||
uploadDumpstateButton.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,
|
||||
getString(R.string.upload_error, errorMessage),
|
||||
Toast.LENGTH_LONG).show();
|
||||
resetUI();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI after upload completion or error
|
||||
*/
|
||||
private void resetUI() {
|
||||
// Get all dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.setText(getString(R.string.dumpstate_found, dumpstateFile.getName()));
|
||||
} else {
|
||||
// Multiple dumpstate files
|
||||
dumpstateStatusText.setText(getString(R.string.multiple_dumpstates_found) +
|
||||
" (" + dumpstateFiles.length + ")");
|
||||
|
||||
// If we have a specific file selected, show it
|
||||
if (dumpstateFile != null && dumpstateFile.exists()) {
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
} else {
|
||||
// Otherwise show the most recent one
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
}
|
||||
}
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
|
||||
// Update multiselect if active
|
||||
if (isDumpstateListExpanded && dumpstateAdapter != null) {
|
||||
loadDumpstateFiles();
|
||||
updateUploadButtonForMultiSelect();
|
||||
}
|
||||
} else {
|
||||
dumpstateFile = null;
|
||||
dumpstateStatusText.setText(R.string.no_dumpstate_found);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
}
|
||||
|
||||
if (selectedFile != null) {
|
||||
selectedFileText.setText(getString(R.string.selected_file, selectedFile.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@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() {
|
||||
// Look for dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file found, use it directly
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.setText(getString(R.string.dumpstate_found, dumpstateFile.getName()));
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
} else {
|
||||
// Multiple dumpstate files found
|
||||
dumpstateStatusText.setText(getString(R.string.multiple_dumpstates_found) +
|
||||
" (" + dumpstateFiles.length + ")");
|
||||
|
||||
// Set the most recent one as default
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
// Show the dumpstate name in a new line
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
|
||||
// Update toggle button visibility
|
||||
toggleDumpstateListButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
} else {
|
||||
dumpstateFile = null;
|
||||
dumpstateStatusText.setText(R.string.no_dumpstate_found);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
toggleDumpstateListButton.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, R.string.permission_not_granted, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, getString(R.string.upload_success, 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;
|
||||
} else if (id == R.id.action_settings) {
|
||||
// Open settings activity
|
||||
startActivity(new Intent(this, SettingsActivity.class));
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.widget.Button;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil;
|
||||
|
||||
/**
|
||||
* Activity for server setup
|
||||
*/
|
||||
public class ServerSetupActivity extends AppCompatActivity {
|
||||
private static final String TAG = AppConstants.APP_TAG + "ServerSetupActivity";
|
||||
|
||||
private TextInputEditText serverAddressInput;
|
||||
private TextInputEditText serverPortInput;
|
||||
private TextInputEditText clientKeyInput;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_server_setup);
|
||||
|
||||
// Initialize UI components
|
||||
serverAddressInput = findViewById(R.id.serverAddressInput);
|
||||
serverPortInput = findViewById(R.id.serverPortInput);
|
||||
clientKeyInput = findViewById(R.id.clientKeyInput);
|
||||
Button finishSetupButton = findViewById(R.id.finishSetupButton);
|
||||
|
||||
// Set up action bar
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setTitle("Server Setup");
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
}
|
||||
|
||||
// Set up button click listener
|
||||
finishSetupButton.setOnClickListener(v -> saveSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to SharedPreferences
|
||||
*/
|
||||
private void saveSettings() {
|
||||
try {
|
||||
// Get values from input fields
|
||||
String serverAddress = serverAddressInput.getText().toString().trim();
|
||||
String serverPortStr = serverPortInput.getText().toString().trim();
|
||||
String clientKey = clientKeyInput.getText().toString().trim();
|
||||
|
||||
// Validate input
|
||||
if (TextUtils.isEmpty(serverAddress)) {
|
||||
serverAddressInput.setError("Server address is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(serverPortStr)) {
|
||||
serverPortInput.setError("Server port is required");
|
||||
return;
|
||||
}
|
||||
|
||||
int serverPort;
|
||||
try {
|
||||
serverPort = Integer.parseInt(serverPortStr);
|
||||
if (serverPort <= 0 || serverPort > 65535) {
|
||||
serverPortInput.setError("Port must be between 1 and 65535");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
serverPortInput.setError("Invalid port number");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(clientKey)) {
|
||||
clientKeyInput.setError("Client key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(this, serverAddress);
|
||||
PreferencesUtil.setServerPort(this, serverPort);
|
||||
PreferencesUtil.setClientKey(this, clientKey);
|
||||
PreferencesUtil.setSetupCompleted(this, true);
|
||||
|
||||
Log.d(TAG, "Server setup completed successfully");
|
||||
Toast.makeText(this, "Setup completed successfully!", Toast.LENGTH_SHORT).show();
|
||||
|
||||
// Navigate to main activity
|
||||
Intent intent = new Intent(ServerSetupActivity.this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving settings", e);
|
||||
Toast.makeText(this, "Error saving settings: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSupportNavigateUp() {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.widget.Button;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil;
|
||||
|
||||
/**
|
||||
* Activity for application settings
|
||||
*/
|
||||
public class SettingsActivity extends AppCompatActivity {
|
||||
private static final String TAG = AppConstants.APP_TAG + "SettingsActivity";
|
||||
|
||||
private TextInputEditText serverAddressInput;
|
||||
private TextInputEditText serverPortInput;
|
||||
private TextInputEditText clientKeyInput;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
|
||||
// Initialize UI components
|
||||
serverAddressInput = findViewById(R.id.serverAddressInput);
|
||||
serverPortInput = findViewById(R.id.serverPortInput);
|
||||
clientKeyInput = findViewById(R.id.clientKeyInput);
|
||||
Button saveButton = findViewById(R.id.saveSettingsButton);
|
||||
Button resetButton = findViewById(R.id.resetSettingsButton);
|
||||
|
||||
// Set up action bar
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle("Settings");
|
||||
}
|
||||
|
||||
// Load current settings
|
||||
loadSettings();
|
||||
|
||||
// Set up button click listeners
|
||||
saveButton.setOnClickListener(v -> saveSettings());
|
||||
resetButton.setOnClickListener(v -> resetSettings());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current settings from SharedPreferences
|
||||
*/
|
||||
private void loadSettings() {
|
||||
String serverAddress = PreferencesUtil.getServerAddress(this);
|
||||
int serverPort = PreferencesUtil.getServerPort(this);
|
||||
String clientKey = PreferencesUtil.getClientKey(this);
|
||||
|
||||
serverAddressInput.setText(serverAddress);
|
||||
if (serverPort > 0) {
|
||||
serverPortInput.setText(String.valueOf(serverPort));
|
||||
} else {
|
||||
serverPortInput.setText("");
|
||||
}
|
||||
clientKeyInput.setText(clientKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to SharedPreferences
|
||||
*/
|
||||
private void saveSettings() {
|
||||
try {
|
||||
// Get values from input fields
|
||||
String serverAddress = serverAddressInput.getText().toString().trim();
|
||||
String serverPortStr = serverPortInput.getText().toString().trim();
|
||||
String clientKey = clientKeyInput.getText().toString().trim();
|
||||
|
||||
// Validate input
|
||||
if (TextUtils.isEmpty(serverAddress)) {
|
||||
serverAddressInput.setError("Server address is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(serverPortStr)) {
|
||||
serverPortInput.setError("Server port is required");
|
||||
return;
|
||||
}
|
||||
|
||||
int serverPort;
|
||||
try {
|
||||
serverPort = Integer.parseInt(serverPortStr);
|
||||
if (serverPort <= 0 || serverPort > 65535) {
|
||||
serverPortInput.setError("Port must be between 1 and 65535");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
serverPortInput.setError("Invalid port number");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(clientKey)) {
|
||||
clientKeyInput.setError("Client key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(this, serverAddress);
|
||||
PreferencesUtil.setServerPort(this, serverPort);
|
||||
PreferencesUtil.setClientKey(this, clientKey);
|
||||
|
||||
Log.d(TAG, "Settings saved successfully");
|
||||
Toast.makeText(this, "Settings saved successfully", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving settings", e);
|
||||
Toast.makeText(this, "Error saving settings: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to default values
|
||||
*/
|
||||
private void resetSettings() {
|
||||
PreferencesUtil.resetToDefaults(this);
|
||||
loadSettings();
|
||||
Toast.makeText(this, "Settings reset to defaults", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSupportNavigateUp() {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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.AlertDialog;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyCodeClick(UploadHistory history) {
|
||||
copyToClipboard(history.getUploadCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeleteClick(UploadHistory 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
/**
|
||||
* Welcome activity for first-time setup
|
||||
*/
|
||||
public class WelcomeActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_welcome);
|
||||
|
||||
// Hide action bar for cleaner look
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().hide();
|
||||
}
|
||||
|
||||
// Set up next button
|
||||
Button nextButton = findViewById(R.id.nextButton);
|
||||
nextButton.setOnClickListener(v -> {
|
||||
// Proceed to server setup activity
|
||||
Intent intent = new Intent(WelcomeActivity.this, ServerSetupActivity.class);
|
||||
startActivity(intent);
|
||||
// No finish() here to allow back navigation
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package com.mattintech.simplelogupload.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.controller.FileController
|
||||
import com.mattintech.simplelogupload.controller.UploadController
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import com.mattintech.simplelogupload.service.FileUploadForegroundService
|
||||
import com.mattintech.simplelogupload.upload.UploadStrategySelector
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* UI state for the main screen
|
||||
*/
|
||||
data class MainUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isPaused: Boolean = false,
|
||||
val isChunkedUpload: Boolean = false,
|
||||
val uploadStatusMessage: String? = null,
|
||||
val selectedFile: File? = null,
|
||||
val selectedFileName: String? = null,
|
||||
val dumpstateFiles: List<File> = emptyList(),
|
||||
val selectedDumpstateIndices: Set<Int> = emptySet(),
|
||||
val uploadResult: UploadResult? = null,
|
||||
val errorMessage: String? = null,
|
||||
val showUploadParamsDialog: Boolean = false,
|
||||
val showUploadResultDialog: Boolean = false,
|
||||
val uploadProgress: Int = 0,
|
||||
val bytesUploaded: Long = 0,
|
||||
val totalBytes: Long = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel for the main screen with upload functionality
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val TAG = AppConstants.APP_TAG + "MainViewModel"
|
||||
|
||||
private val uploadController = UploadController(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var uploadReceiver: BroadcastReceiver? = null
|
||||
|
||||
// Track current upload parameters for result construction
|
||||
private var currentUploadFileName: String? = null
|
||||
private var currentUploadMaxDownloads: Int = 0
|
||||
private var currentUploadExpiryHours: Int = 0
|
||||
private var currentUploadTempZipFile: File? = null
|
||||
|
||||
init {
|
||||
loadDumpstateFiles()
|
||||
registerUploadReceiver()
|
||||
|
||||
// Check if there's an ongoing upload from a previous session
|
||||
if (PreferencesUtil.isUploadInProgress(application)) {
|
||||
val fileName = PreferencesUtil.getUploadFileName(application)
|
||||
Log.d(TAG, "Detected ongoing upload: $fileName")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register broadcast receiver for upload completion
|
||||
*/
|
||||
private fun registerUploadReceiver() {
|
||||
uploadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
AppConstants.ACTION_ZIP_PROGRESS -> {
|
||||
val percentComplete = intent.getIntExtra(AppConstants.EXTRA_PERCENT_COMPLETE, 0)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
uploadProgress = percentComplete,
|
||||
uploadStatusMessage = "Compressing file: $percentComplete%"
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_PROGRESS -> {
|
||||
val bytesUploaded = intent.getLongExtra(AppConstants.EXTRA_BYTES_UPLOADED, 0)
|
||||
val totalBytes = intent.getLongExtra(AppConstants.EXTRA_TOTAL_BYTES, 0)
|
||||
val percentComplete = intent.getIntExtra(AppConstants.EXTRA_PERCENT_COMPLETE, 0)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
uploadProgress = percentComplete,
|
||||
bytesUploaded = bytesUploaded,
|
||||
totalBytes = totalBytes
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_COMPLETE -> {
|
||||
val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE)
|
||||
Log.d(TAG, "Upload completed: $code")
|
||||
|
||||
// Create proper success UploadResult with tracked parameters
|
||||
val result = if (code != null && currentUploadFileName != null) {
|
||||
UploadResult(
|
||||
code,
|
||||
currentUploadFileName!!,
|
||||
currentUploadMaxDownloads,
|
||||
currentUploadExpiryHours
|
||||
)
|
||||
} else {
|
||||
// Fallback for error case
|
||||
UploadResult("Upload completed but code is missing")
|
||||
}
|
||||
|
||||
// Clean up temporary zip file if it exists
|
||||
currentUploadTempZipFile?.let { tempFile ->
|
||||
if (tempFile.exists()) {
|
||||
val deleted = tempFile.delete()
|
||||
Log.d(TAG, "Cleaned up temp zip file after upload: ${tempFile.name}, deleted: $deleted")
|
||||
}
|
||||
currentUploadTempZipFile = null
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
uploadResult = result,
|
||||
showUploadResultDialog = true,
|
||||
uploadProgress = 0,
|
||||
bytesUploaded = 0,
|
||||
totalBytes = 0
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_ERROR -> {
|
||||
val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR)
|
||||
Log.e(TAG, "Upload error: $error")
|
||||
|
||||
// Clean up temporary zip file if it exists
|
||||
currentUploadTempZipFile?.let { tempFile ->
|
||||
if (tempFile.exists()) {
|
||||
val deleted = tempFile.delete()
|
||||
Log.d(TAG, "Cleaned up temp zip file after error: ${tempFile.name}, deleted: $deleted")
|
||||
}
|
||||
currentUploadTempZipFile = null
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error ?: "Unknown error",
|
||||
uploadProgress = 0,
|
||||
bytesUploaded = 0,
|
||||
totalBytes = 0
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_PAUSED -> {
|
||||
Log.d(TAG, "Upload paused")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPaused = true,
|
||||
uploadStatusMessage = "Upload paused"
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_RESUMED -> {
|
||||
Log.d(TAG, "Upload resumed")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPaused = false,
|
||||
uploadStatusMessage = "Resuming upload..."
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_CANCELLED -> {
|
||||
Log.d(TAG, "Upload cancelled")
|
||||
|
||||
// Clean up temporary zip file if it exists
|
||||
currentUploadTempZipFile?.let { tempFile ->
|
||||
if (tempFile.exists()) {
|
||||
val deleted = tempFile.delete()
|
||||
Log.d(TAG, "Cleaned up temp zip file after cancellation: ${tempFile.name}, deleted: $deleted")
|
||||
}
|
||||
currentUploadTempZipFile = null
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isPaused = false,
|
||||
uploadProgress = 0,
|
||||
bytesUploaded = 0,
|
||||
totalBytes = 0,
|
||||
uploadStatusMessage = "Upload cancelled"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(AppConstants.ACTION_ZIP_PROGRESS)
|
||||
addAction(AppConstants.ACTION_UPLOAD_PROGRESS)
|
||||
addAction(AppConstants.ACTION_UPLOAD_COMPLETE)
|
||||
addAction(AppConstants.ACTION_UPLOAD_ERROR)
|
||||
addAction(AppConstants.ACTION_UPLOAD_PAUSED)
|
||||
addAction(AppConstants.ACTION_UPLOAD_RESUMED)
|
||||
addAction(AppConstants.ACTION_UPLOAD_CANCELLED)
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(getApplication())
|
||||
.registerReceiver(uploadReceiver!!, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all dumpstate files from device
|
||||
*/
|
||||
fun loadDumpstateFiles() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val files = FileController.getAllDumpStateFiles()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
dumpstateFiles = files.toList()
|
||||
)
|
||||
Log.d(TAG, "Loaded ${files.size} dumpstate files")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading dumpstate files", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Error loading dumpstate files: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection from file picker
|
||||
*/
|
||||
fun handleFileSelection(uri: Uri) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Get filename from URI
|
||||
val fileName = getFileNameFromUri(uri)
|
||||
Log.d(TAG, "File selected from URI: $fileName")
|
||||
|
||||
// Convert URI to File using FileController
|
||||
val file = FileController.saveContentUriToFile(
|
||||
getApplication(),
|
||||
uri,
|
||||
fileName
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedFileName = fileName,
|
||||
selectedFile = file
|
||||
)
|
||||
Log.d(TAG, "File saved to cache: ${file.absolutePath}, size: ${file.length()} bytes")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling file selection", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Error selecting file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename from URI using ContentResolver
|
||||
*/
|
||||
private fun getFileNameFromUri(uri: Uri): String {
|
||||
var fileName = "selected_file_${System.currentTimeMillis()}"
|
||||
|
||||
// Try to get the display name from the content resolver
|
||||
try {
|
||||
getApplication<Application>().contentResolver.query(
|
||||
uri, null, null, null, null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex != -1) {
|
||||
fileName = cursor.getString(nameIndex) ?: fileName
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not get filename from URI, using default", e)
|
||||
// Fall back to using last path segment if available
|
||||
fileName = uri.lastPathSegment ?: fileName
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dumpstate file selection
|
||||
*/
|
||||
fun toggleDumpstateSelection(index: Int) {
|
||||
val currentSelections = _uiState.value.selectedDumpstateIndices.toMutableSet()
|
||||
if (currentSelections.contains(index)) {
|
||||
currentSelections.remove(index)
|
||||
} else {
|
||||
currentSelections.add(index)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = currentSelections
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all dumpstate files
|
||||
*/
|
||||
fun selectAllDumpstates() {
|
||||
val allIndices = _uiState.value.dumpstateFiles.indices.toSet()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = allIndices
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dumpstate selections
|
||||
*/
|
||||
fun clearDumpstateSelections() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = emptySet()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show upload parameters dialog
|
||||
*/
|
||||
fun showUploadParamsDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadParamsDialog = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide upload parameters dialog
|
||||
*/
|
||||
fun hideUploadParamsDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide upload result dialog
|
||||
*/
|
||||
fun hideUploadResultDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadResultDialog = false,
|
||||
uploadResult = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a general file using foreground service
|
||||
*/
|
||||
fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
|
||||
try {
|
||||
// Track upload parameters for result construction
|
||||
currentUploadFileName = file.name
|
||||
currentUploadMaxDownloads = maxDownloads
|
||||
currentUploadExpiryHours = expiryHours
|
||||
|
||||
// Determine if this will be a chunked upload
|
||||
val isChunked = UploadStrategySelector.shouldUseChunkedUpload(getApplication(), file)
|
||||
Log.d(TAG, "Upload will use ${if (isChunked) "chunked" else "standard"} strategy")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
isChunkedUpload = isChunked,
|
||||
isPaused = false,
|
||||
uploadStatusMessage = null,
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
|
||||
Log.d(TAG, "Starting upload for file: ${file.name}")
|
||||
|
||||
// Start foreground service for upload with notification
|
||||
uploadController.startUploadService(file, maxDownloads, expiryHours)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting upload service", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error starting upload: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload selected dumpstate files
|
||||
* If multiple files are selected, combines them into a ZIP first
|
||||
*/
|
||||
fun uploadSelectedDumpstates(maxDownloads: Int, expiryHours: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
var zipFile: File? = null
|
||||
try {
|
||||
val files = _uiState.value.selectedDumpstateIndices
|
||||
.map { _uiState.value.dumpstateFiles[it] }
|
||||
|
||||
if (files.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "No dumpstate files selected"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
|
||||
Log.d(TAG, "Starting upload for ${files.size} dumpstate file(s)")
|
||||
|
||||
val fileToUpload: File
|
||||
|
||||
if (files.size == 1) {
|
||||
// Single file: upload directly
|
||||
fileToUpload = files.first()
|
||||
currentUploadTempZipFile = null
|
||||
Log.d(TAG, "Uploading single dumpstate file: ${fileToUpload.name}")
|
||||
} else {
|
||||
// Multiple files: zip them first
|
||||
Log.d(TAG, "Zipping ${files.size} dumpstate files")
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val zipFileName = "dumpstates_$timestamp"
|
||||
val cacheDir = getApplication<Application>().cacheDir
|
||||
|
||||
zipFile = ZipUtil.zipFiles(files, cacheDir, zipFileName, null)
|
||||
fileToUpload = zipFile
|
||||
currentUploadTempZipFile = zipFile
|
||||
Log.d(TAG, "Created zip file: ${fileToUpload.name} (${fileToUpload.length()} bytes)")
|
||||
}
|
||||
|
||||
// Track upload parameters for result construction
|
||||
currentUploadFileName = fileToUpload.name
|
||||
currentUploadMaxDownloads = maxDownloads
|
||||
currentUploadExpiryHours = expiryHours
|
||||
|
||||
// Determine if this will be a chunked upload
|
||||
val isChunked = UploadStrategySelector.shouldUseChunkedUpload(getApplication(), fileToUpload)
|
||||
Log.d(TAG, "Upload will use ${if (isChunked) "chunked" else "standard"} strategy")
|
||||
|
||||
// Update state with upload type
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isChunkedUpload = isChunked,
|
||||
isPaused = false,
|
||||
uploadStatusMessage = null
|
||||
)
|
||||
|
||||
uploadController.startUploadService(fileToUpload, maxDownloads, expiryHours)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading dumpstate files", e)
|
||||
|
||||
// Clean up zip file if it was created
|
||||
zipFile?.let {
|
||||
if (it.exists()) {
|
||||
val deleted = it.delete()
|
||||
Log.d(TAG, "Cleaned up zip file: ${it.name}, deleted: $deleted")
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading files: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current upload
|
||||
*/
|
||||
fun pauseUpload() {
|
||||
Log.d(TAG, "Pause upload requested")
|
||||
val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_PAUSE_UPLOAD
|
||||
}
|
||||
getApplication<Application>().startService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the paused upload
|
||||
*/
|
||||
fun resumeUpload() {
|
||||
Log.d(TAG, "Resume upload requested")
|
||||
val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_RESUME_UPLOAD
|
||||
}
|
||||
getApplication<Application>().startService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current upload
|
||||
*/
|
||||
fun cancelUpload() {
|
||||
Log.d(TAG, "Cancel upload requested")
|
||||
val intent = Intent(getApplication(), FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_CANCEL_UPLOAD
|
||||
}
|
||||
getApplication<Application>().startService(intent)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Unregister broadcast receiver
|
||||
uploadReceiver?.let {
|
||||
LocalBroadcastManager.getInstance(getApplication()).unregisterReceiver(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/res/drawable/ic_qr_scan.xml
Normal file
39
app/src/main/res/drawable/ic_qr_scan.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,21h8v-8H3V21zM5,15h4v4H5V15z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,3v8h8V3H13zM19,9h-4V5h4V9z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,19h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,13h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15,15h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,17h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15,19h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,17h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17,13h2v2h-2z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,15h2v2h-2z"/>
|
||||
</vector>
|
||||
@@ -1,200 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headerText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_header"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp"/>
|
||||
|
||||
<!-- Main Upload Options Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/uploadOptionsCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/headerText">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/upload_options_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectFileButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/select_file_button"
|
||||
android:drawableStart="@android:drawable/ic_menu_search"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/uploadDumpstateButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/upload_dumpstate_button"
|
||||
android:drawableStart="@android:drawable/ic_menu_save"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"/>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Dumpstate Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/dumpstateCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/uploadOptionsCard">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dumpstate_section_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dumpstateStatusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/looking_for_dumpstate"
|
||||
android:textSize="16sp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="start"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggleDumpstateListButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/show_dumpstates"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Small" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/dumpstateRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/multiSelectButtonsLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/select_all"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/clearSelectionsButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/clear_selections"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Selected File Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/selectedFileCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/dumpstateCard">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/selected_file_section_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectedFileText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/no_file_selected"
|
||||
android:textSize="16sp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/uploadSelectedFileButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/upload_selected_file"
|
||||
android:visibility="gone"/>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/selectedFileCard"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,113 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/setupTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="Server Configuration"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/setupDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Please enter your server details below. These are required to connect to your SimpleLogUpload server."
|
||||
android:textAlignment="center"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/setupTitle" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/finishSetupButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/setupDescription">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/serverAddressLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="Server Address (IP or hostname)">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverAddressInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/serverPortLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="Server Port">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverPortInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/clientKeyLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="Client Key">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/clientKeyInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/testConnectionInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="You can test your connection after saving these settings."
|
||||
android:textAlignment="center"
|
||||
android:textStyle="italic" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
android:id="@+id/finishSetupButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save and Continue"
|
||||
android:padding="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,86 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView 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="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
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="Server Configuration"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Server Address">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverAddressInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Server Port">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverPortInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Authentication"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="Client Key"
|
||||
app:endIconMode="password_toggle">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/clientKeyInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/saveSettingsButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save Settings"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/resetSettingsButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Reset to Defaults"
|
||||
android:layout_marginTop="8dp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/logoImage"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:src="@drawable/ic_app_icon"
|
||||
android:contentDescription="SimpleLogUpload logo"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Welcome to SimpleLogUpload"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/logoImage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Before you can use this app, you need to configure it to connect to your SimpleLogUpload server instance."
|
||||
android:textAlignment="center"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="The app requires:\n\n• Server Address - IP or hostname of your server\n• Server Port - Port your server is running on\n• Client Key - Authentication key for secure uploads\n\nYour administrator should have provided these details."
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/welcomeDescription" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/nextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="Continue to Setup"
|
||||
android:padding="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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:id="@+id/titleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dumpstate_selection_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/dumpstateRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxHeight="300dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/no_dumpstate_found"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
android:padding="16dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@android:string/cancel"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_button"
|
||||
android:enabled="false"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,97 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
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:padding="8dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/checkBox"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileNameText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:ellipsize="start"
|
||||
android:singleLine="true"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileDateText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/fileNameText"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileSizeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/fileDateText"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,70 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
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:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dialogTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Upload Successful"
|
||||
android:textAlignment="center"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/codeLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Your upload code:"
|
||||
android:textAlignment="center"
|
||||
app:layout_constraintTop_toBottomOf="@id/dialogTitle"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/uploadCode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#007BFF"
|
||||
app:layout_constraintTop_toBottomOf="@id/codeLabel"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/copyButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="Copy Code"
|
||||
app:layout_constraintTop_toBottomOf="@id/uploadCode"
|
||||
app:layout_constraintStart_toStartOf="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>
|
||||
@@ -67,4 +67,13 @@
|
||||
<string name="expiry_time_note">Maximum 24 hours (1 day)</string>
|
||||
<string name="cancel_button">Cancel</string>
|
||||
<string name="upload_button">Upload</string>
|
||||
|
||||
<!-- QR Code Scanning -->
|
||||
<string name="scan_qr_code_button">Scan QR Code</string>
|
||||
<string name="scan_qr_code_hint">Scan a QR code from your server admin panel to auto-fill settings</string>
|
||||
<string name="qr_scan_success">Configuration loaded from QR code</string>
|
||||
<string name="qr_scan_cancelled">QR code scan cancelled</string>
|
||||
<string name="qr_scan_error">QR code error: %1$s</string>
|
||||
<string name="camera_permission_required">Camera permission is required to scan QR codes</string>
|
||||
<string name="camera_permission_denied">Camera permission denied. Please enable it in Settings to scan QR codes.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x
|
||||
ext.kotlin_version = '1.9.22' // Updated for Compose Compiler 1.5.8 compatibility
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.9.1' // More stable version
|
||||
classpath 'com.android.tools.build:gradle:8.3.2' // More stable version
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
141
docs/CHUNKED_UPLOAD.md
Normal file
141
docs/CHUNKED_UPLOAD.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Chunked Upload Implementation
|
||||
|
||||
This document describes the chunked upload implementation added to the SimpleLogUpload Android application to solve issues with large file uploads on mobile networks.
|
||||
|
||||
## Overview
|
||||
|
||||
The chunked upload system splits large files into smaller pieces (chunks), uploads them individually, and then reassembles them on the server. This approach provides several benefits:
|
||||
|
||||
1. Better reliability on unstable networks (especially 5G/mobile)
|
||||
2. Ability to resume interrupted uploads
|
||||
3. More accurate progress reporting
|
||||
4. Adaptability to different network conditions
|
||||
|
||||
## Components
|
||||
|
||||
### 1. ChunkedUploadManager
|
||||
|
||||
The core component that handles:
|
||||
- Splitting files into chunks
|
||||
- Managing parallel uploads
|
||||
- Tracking upload progress
|
||||
- Handling network changes
|
||||
- Implementing retry logic
|
||||
- Verifying chunk status with server
|
||||
- Automatically retrying missing chunks
|
||||
|
||||
```java
|
||||
ChunkedUploadManager uploadManager = new ChunkedUploadManager(
|
||||
context, file, maxDownloads, expiryHours);
|
||||
```
|
||||
|
||||
### 2. UploadStrategySelector
|
||||
|
||||
Determines whether to use chunked uploads based on:
|
||||
- File size
|
||||
- Network type (WiFi vs. Mobile)
|
||||
- Configurable thresholds
|
||||
|
||||
```java
|
||||
boolean useChunked = UploadStrategySelector.shouldUseChunkedUpload(context, file);
|
||||
```
|
||||
|
||||
### 3. Modified UploadController
|
||||
|
||||
The existing UploadController now:
|
||||
- Selects between standard and chunked uploads
|
||||
- Handles both upload methods with a unified API
|
||||
- Provides progress updates
|
||||
|
||||
## API Requirements
|
||||
|
||||
The server implements four endpoints:
|
||||
|
||||
1. `/upload/start` - Initializes a chunked upload session
|
||||
- Accepts: filename, filesize, chunk_size, total_chunks, max_downloads, expiry_hours
|
||||
- Returns: upload_id
|
||||
|
||||
2. `/upload/chunk` - Handles individual chunk uploads
|
||||
- Accepts: upload_id, chunk_index, total_chunks, chunk (binary)
|
||||
- Returns: success/failure
|
||||
|
||||
3. `/upload/verify` - Verifies which chunks have been received
|
||||
- Accepts: upload_id
|
||||
- Returns: received_chunks, missing_chunks, total_chunks
|
||||
|
||||
4. `/upload/complete` - Finalizes an upload by reassembling chunks
|
||||
- Accepts: upload_id, filename, total_chunks
|
||||
- Returns: upload code (same as standard upload)
|
||||
|
||||
## Configuration
|
||||
|
||||
The chunked upload system is configured with the following parameters:
|
||||
|
||||
- WiFi chunk size: 10MB
|
||||
- Mobile chunk size: 2MB
|
||||
- WiFi threshold: 50MB (files larger than this use chunked upload)
|
||||
- Mobile threshold: 10MB
|
||||
- WiFi parallel uploads: 3
|
||||
- Mobile parallel uploads: 1
|
||||
|
||||
These values can be adjusted in the `ChunkedUploadManager` and `UploadStrategySelector` classes.
|
||||
|
||||
## Enhanced Reliability Features
|
||||
|
||||
### Chunk Verification
|
||||
|
||||
The system implements robust verification to ensure all chunks are properly uploaded:
|
||||
|
||||
1. Client-side tracking of all uploaded chunks by index
|
||||
2. Server endpoint to verify which chunks have been received
|
||||
3. Automatic reupload of any missing chunks
|
||||
4. Verification that files actually exist on disk before finalizing
|
||||
|
||||
### Network Handling
|
||||
|
||||
The implementation includes robust network handling:
|
||||
|
||||
- Detects network type (WiFi/Mobile/5G/LTE)
|
||||
- Adjusts chunk size and parallel uploads accordingly
|
||||
- Monitors network state changes
|
||||
- Pauses uploads on network loss
|
||||
- Resumes uploads when network is restored
|
||||
- Adapts to network type changes during upload
|
||||
|
||||
### Progressive Retry
|
||||
|
||||
Implements intelligent retry logic:
|
||||
|
||||
- Specific retry of only failed chunks
|
||||
- Server-client synchronization of chunk status
|
||||
- Automatic recovery from partial upload failures
|
||||
- Verification before finalization to catch missing chunks
|
||||
|
||||
## Testing
|
||||
|
||||
To test this implementation:
|
||||
|
||||
1. Ensure the server has the required endpoints implemented
|
||||
2. Test with large files (100MB+) on both WiFi and mobile networks
|
||||
3. Test with network transitions (WiFi -> Mobile)
|
||||
4. Test interrupting uploads and resuming them
|
||||
5. Compare performance with the previous implementation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check logs for entries from `ChunkedUploadManager` and `UploadController`
|
||||
- Look for network-related logs to identify connection issues
|
||||
- Check server logs for chunk verification and missing chunk information
|
||||
- Verify server-side chunk handling is working correctly
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- ✅ Basic chunked upload system
|
||||
- ✅ Network type detection and adaptivity
|
||||
- ✅ Chunk verification and automatic retry
|
||||
- ✅ Resume capability for interrupted uploads
|
||||
- ✅ Parallel chunk uploading
|
||||
- ✅ Network state monitoring
|
||||
- ✅ Progressive backoff and retry strategy
|
||||
- ✅ Enhanced progress tracking
|
||||
|
||||
316
docs/LARGE_UPLOAD.md
Normal file
316
docs/LARGE_UPLOAD.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Large File Upload Analysis and Improvements
|
||||
|
||||
## Current Implementation Analysis
|
||||
|
||||
### Client Side (Android)
|
||||
|
||||
The SimpleLogUpload Android application is designed to upload large log files (up to 220MB) to a simple file upload server. The current implementation uses:
|
||||
|
||||
- OkHttp3 for HTTP requests
|
||||
- A single monolithic file upload in one HTTP request
|
||||
- Fixed network timeouts:
|
||||
- Connection timeout: 60 seconds
|
||||
- Write timeout: 180 seconds (3 minutes)
|
||||
- Read timeout: 60 seconds
|
||||
- Basic retry on connection failure
|
||||
|
||||
Behavior by network type:
|
||||
- **WiFi**: Uploads work successfully
|
||||
- **5G/Mobile**: Socket timeouts occur during upload (`java.net.SocketException: Socket closed`)
|
||||
|
||||
### Server Side (Python/Flask)
|
||||
|
||||
The server is a simple Flask application with:
|
||||
- Standard file upload endpoint (`/upload`)
|
||||
- No explicit chunked upload support
|
||||
- Uses Gunicorn as WSGI server without specific timeout configurations
|
||||
- No specific optimizations for large files
|
||||
|
||||
## Problem Root Causes
|
||||
|
||||
1. **Mobile Network Limitations**:
|
||||
- Mobile carriers may have connection restrictions for large uploads
|
||||
- 5G networks may have more aggressive connection management
|
||||
- Network fluctuations are more common in mobile networks
|
||||
|
||||
2. **Timeout Configuration**:
|
||||
- Current timeout settings may be insufficient for large files over mobile networks
|
||||
- Server and client timeouts aren't aligned
|
||||
|
||||
3. **Single Upload Approach**:
|
||||
- The entire 220MB file is uploaded in a single HTTP request
|
||||
- No resume capability if connection drops
|
||||
- No progress tracking or partial file recovery
|
||||
|
||||
4. **Missing Network Adaptability**:
|
||||
- No differentiation between WiFi and mobile network settings
|
||||
- No adaptive timeout or retry mechanisms
|
||||
- No connection monitoring during upload
|
||||
|
||||
## Recommended Solutions
|
||||
|
||||
### 1. Implement Chunked Uploading
|
||||
|
||||
Develop a proper chunked upload system with these features:
|
||||
|
||||
```java
|
||||
// Example chunked upload strategy
|
||||
public class ChunkedUploadManager {
|
||||
// Adjust these based on testing
|
||||
private static final int DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
|
||||
private static final int MAX_RETRY_ATTEMPTS = 3;
|
||||
private static final int RETRY_DELAY_MS = 1000;
|
||||
|
||||
// File metadata
|
||||
private File file;
|
||||
private int chunkSize;
|
||||
private int totalChunks;
|
||||
private String uploadId;
|
||||
|
||||
// Network state
|
||||
private boolean isWifiConnection;
|
||||
private boolean isPaused;
|
||||
|
||||
// Tracking
|
||||
private int currentChunkIndex;
|
||||
private Set<Integer> uploadedChunks = new HashSet<>();
|
||||
|
||||
public ChunkedUploadManager(File file, boolean isWifiConnection) {
|
||||
this.file = file;
|
||||
this.isWifiConnection = isWifiConnection;
|
||||
|
||||
// Adjust chunk size based on network type
|
||||
this.chunkSize = isWifiConnection ? DEFAULT_CHUNK_SIZE : (DEFAULT_CHUNK_SIZE / 2);
|
||||
|
||||
this.totalChunks = (int) Math.ceil((double) file.length() / chunkSize);
|
||||
}
|
||||
|
||||
// Methods for initialization, chunk upload, tracking, etc.
|
||||
}
|
||||
```
|
||||
|
||||
**Server Changes Implemented**:
|
||||
```python
|
||||
# New endpoint for chunk verification
|
||||
@app.route('/upload/verify', methods=['POST'])
|
||||
@require_client_key
|
||||
def verify_chunks():
|
||||
# Get upload ID from request
|
||||
upload_id = request.form.get('upload_id')
|
||||
|
||||
# Get session metadata
|
||||
session = metadata[upload_id]
|
||||
|
||||
# Calculate missing chunks
|
||||
received_chunks = set(session['received_chunks'])
|
||||
all_chunks = set(range(session['total_chunks']))
|
||||
missing_chunks = list(all_chunks - received_chunks)
|
||||
|
||||
# Verify files exist on disk
|
||||
for chunk_index in range(session['total_chunks']):
|
||||
chunk_path = os.path.join(session['chunk_dir'], f'chunk_{chunk_index}')
|
||||
if not os.path.exists(chunk_path) and chunk_index in received_chunks:
|
||||
# Remove from received_chunks if file is missing
|
||||
session['received_chunks'].remove(chunk_index)
|
||||
|
||||
# Return missing chunks information
|
||||
return jsonify({
|
||||
'total_chunks': session['total_chunks'],
|
||||
'received_chunks': len(session['received_chunks']),
|
||||
'missing_chunks': missing_chunks
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Adaptive Network Settings
|
||||
|
||||
Implement network-aware settings that adjust based on connection type:
|
||||
|
||||
```java
|
||||
private void configureNetworkSettings() {
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
|
||||
boolean isWifi = activeNetwork != null && activeNetwork.getType() == ConnectivityManager.TYPE_WIFI;
|
||||
|
||||
// Adjust settings based on network type
|
||||
if (isWifi) {
|
||||
// WiFi settings - can be more aggressive
|
||||
CONNECTION_TIMEOUT = 60; // seconds
|
||||
WRITE_TIMEOUT = 180; // seconds
|
||||
READ_TIMEOUT = 60; // seconds
|
||||
CHUNK_SIZE = 10_485_760; // 10MB
|
||||
} else {
|
||||
// Mobile network settings - more conservative
|
||||
CONNECTION_TIMEOUT = 30; // seconds
|
||||
WRITE_TIMEOUT = 90; // seconds
|
||||
READ_TIMEOUT = 30; // seconds
|
||||
CHUNK_SIZE = 2_097_152; // 2MB
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Network Monitoring and Handling
|
||||
|
||||
Implement network state monitoring to handle:
|
||||
- Network type changes during upload
|
||||
- Connection losses
|
||||
- Bandwidth fluctuations
|
||||
|
||||
```java
|
||||
private void registerNetworkCallback() {
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
NetworkRequest.Builder builder = new NetworkRequest.Builder();
|
||||
|
||||
cm.registerNetworkCallback(builder.build(), new ConnectivityManager.NetworkCallback() {
|
||||
@Override
|
||||
public void onAvailable(Network network) {
|
||||
// Resume upload if paused
|
||||
resumeUploadIfPaused();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network) {
|
||||
// Pause upload
|
||||
pauseUpload();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) {
|
||||
// Adjust strategies based on new network capabilities
|
||||
boolean isUnmetered = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
|
||||
adjustUploadParameters(isUnmetered);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Progressive Backoff and Retry Strategy
|
||||
|
||||
Implement a more sophisticated retry mechanism:
|
||||
|
||||
```java
|
||||
private void uploadChunkWithRetry(int chunkIndex) {
|
||||
int retryCount = 0;
|
||||
int maxRetries = 5;
|
||||
long backoffMs = 1000; // Start with 1 second
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// Attempt to upload chunk
|
||||
boolean success = uploadChunk(chunkIndex);
|
||||
if (success) {
|
||||
return; // Success, exit retry loop
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Chunk upload failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
// Calculate backoff with exponential increase and jitter
|
||||
long jitter = (long) (Math.random() * 0.3 * backoffMs);
|
||||
long sleepTime = backoffMs + jitter;
|
||||
|
||||
Log.d(TAG, "Retrying chunk " + chunkIndex + " after " + sleepTime + "ms (attempt " + retryCount + ")");
|
||||
|
||||
try {
|
||||
Thread.sleep(sleepTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Increase backoff for next iteration
|
||||
backoffMs = Math.min(backoffMs * 2, 30000); // Cap at 30 seconds
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
Log.e(TAG, "All retry attempts failed for chunk " + chunkIndex);
|
||||
reportChunkFailed(chunkIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Enhanced Progress Tracking and Resumability
|
||||
|
||||
Implement more granular progress tracking for better user experience and resumability:
|
||||
|
||||
```java
|
||||
public class UploadProgress {
|
||||
private long totalBytes;
|
||||
private long uploadedBytes;
|
||||
private int totalChunks;
|
||||
private Set<Integer> completedChunks;
|
||||
private boolean isComplete;
|
||||
private String sessionId;
|
||||
|
||||
// Persist progress to disk for resumability
|
||||
public void saveProgress() {
|
||||
SharedPreferences prefs = context.getSharedPreferences("upload_progress", Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
|
||||
// Store progress data
|
||||
editor.putString("session_" + sessionId + "_chunks", TextUtils.join(",", completedChunks));
|
||||
editor.putLong("session_" + sessionId + "_bytes", uploadedBytes);
|
||||
editor.putBoolean("session_" + sessionId + "_complete", isComplete);
|
||||
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
// Load progress for resuming uploads
|
||||
public static UploadProgress loadProgress(Context context, String sessionId) {
|
||||
// Implementation details
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Server-Side Optimizations
|
||||
|
||||
Modify the server to better handle large file uploads:
|
||||
|
||||
1. Implement explicit chunked upload support
|
||||
2. Configure Gunicorn timeouts appropriately:
|
||||
```
|
||||
gunicorn wsgi:app --bind 0.0.0.0:7777 --timeout 300 --workers 4
|
||||
```
|
||||
3. Add support for resumable uploads with progress tracking
|
||||
4. Implement temporary file cleanup for abandoned uploads
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **First Phase**:
|
||||
- Implement basic chunked uploading
|
||||
- Add network type detection and adaptive settings
|
||||
- Configure server for higher timeouts
|
||||
|
||||
2. **Second Phase**:
|
||||
- Add upload resumability
|
||||
- Implement network state monitoring
|
||||
- Enhanced progress tracking
|
||||
- Progressive retry mechanism
|
||||
|
||||
3. **Final Phase**:
|
||||
- Server-side optimizations for chunked uploads
|
||||
- Background upload functionality
|
||||
- Bandwidth control options
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Test uploading the same 220MB file under various network conditions:
|
||||
- Stable WiFi
|
||||
- Congested WiFi
|
||||
- 5G with strong signal
|
||||
- 5G with weak signal
|
||||
- 4G fallback
|
||||
- Network transitions (WiFi → Mobile)
|
||||
|
||||
2. Measure and compare:
|
||||
- Success rate of uploads
|
||||
- Total upload time
|
||||
- Battery consumption
|
||||
- Data usage efficiency
|
||||
|
||||
## Conclusion
|
||||
|
||||
The socket timeout issues on 5G networks stem from trying to upload large files in a single HTTP request without adapting to the characteristics of mobile networks. By implementing chunked uploads with resume capability, network-aware configurations, and robust error handling, we can create a more reliable upload solution for all network types.
|
||||
|
||||
The most critical issue to address is the lack of chunking, which leaves the application vulnerable to connection interruptions, particularly on mobile networks where connections may be less stable than WiFi.
|
||||
@@ -1,6 +1,11 @@
|
||||
android.suppressUnsupportedCompileSdk=36
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable Gradle toolchain auto-detection and auto-download
|
||||
org.gradle.java.installations.auto-detect=true
|
||||
org.gradle.java.installations.auto-download=true
|
||||
org.gradle.java.installations.paths=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# 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 \
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in New Issue
Block a user