16 Commits

Author SHA1 Message Date
eff3478a97 Fix ANR during chunked uploads and file compression
Major Changes:
- Convert ChunkedUploadManager.java to Kotlin with coroutines
- Convert ZipUtil.java to Kotlin with Dispatchers.IO
- Convert FileController.java to Kotlin

ANR Fixes:
- All file I/O now runs on Dispatchers.IO (prevents main thread blocking)
- Chunk reading (4-10MB) moved to background thread
- File compression (zipping) moved to background thread
- Instant cancellation with proper coroutine handling

Chunking Improvements:
- Update thresholds to match chunk sizes (4MB mobile, 10MB WiFi)
- Files smaller than chunk size use standard upload
- Proper zipping logic: skip single .zip files, zip everything else

Progress & UX:
- Add PREPARING state for file compression phase
- Show 'Compressing file: X%' in notification during zip
- Throttle updates to 500ms for smooth notification display
- Broadcast ACTION_ZIP_PROGRESS for in-app UI
- ViewModel listens and displays zip progress
- Cancel button visible and functional during all phases

Cancellation:
- Instant cancel during zipping (checks every 8KB)
- Clean cancellation using CancellationException
- Automatic cleanup of partial zip files
- No error logs for user-initiated cancellation

Files Changed:
- ChunkedUploadManager: Java → Kotlin, uses coroutines + Dispatchers.IO
- ZipUtil: Java → Kotlin, cancellable with progress throttling
- FileController: Java → Kotlin, proper async job management
- UploadStrategySelector: Updated chunk size thresholds
- FileUploadForegroundService: Added PREPARING state, zip progress handling
- MainViewModel: Listen for ACTION_ZIP_PROGRESS, display status
- AppConstants: Added ACTION_ZIP_PROGRESS constant
2025-12-27 22:56:07 -05:00
04b3bea454 Add configurable parallel uploads and improve chunk upload settings
- Add parallel uploads toggle in settings (experimental, disabled by default)
- Increase mobile chunk size from 2MB to 4MB
- Configure parallel uploads: 3 chunks on WiFi, 2 on mobile (if enabled), 1 sequential (if disabled)
- Fix progress tracking when retrying missing chunks (subtract bytes to prevent >100%)
2025-12-27 22:20:01 -05:00
41363028aa Fix general upload tab file selection
- Implement URI to File conversion using FileController.saveContentUriToFile()
- Properly extract filename from URI using ContentResolver
- Store actual File object instead of null, enabling uploads to work
2025-12-27 22:12:19 -05:00
d40814ec4c Add pause, resume, and cancel controls for uploads
- Add pause/resume/cancel actions to notification bar
- Add pause/resume/cancel buttons to in-app upload progress card
- Track upload state (paused, uploading, cancelled) and upload type (chunked vs standard)
- Chunked uploads support pause/resume, standard uploads only support cancel
- Fix notification to persist cancelled state without flickering
2025-12-27 22:09:14 -05:00
c80d523972 Add multi-file zip upload and persistent upload state tracking
- Implement automatic zipping when multiple dumpstate files are selected
  - Single file uploads directly (preserves existing behavior)
  - Multiple files are zipped with timestamp naming (e.g., dumpstates_20251227_205530.zip)
  - Zip files stored in cache directory and cleaned up after upload completes or on error

- Add upload progress persistence across app restarts
  - Track upload state in SharedPreferences to detect ongoing uploads
  - ViewModel checks for in-progress uploads on initialization and shows loading state
  - Fixes crash when reopening app during upload by properly maintaining state

- Improve upload completion notification
  - Display upload code in notification text (e.g., "Upload complete! Code: ABC123")
  - Use stopForeground(STOP_FOREGROUND_DETACH) to keep notification visible after service stops
  - Notification persists until user dismisses it, ensuring code is always visible
2025-12-27 21:05:26 -05:00
e7335977c0 Add persistent upload progress indicator across all tabs
- Add progress broadcast from service to ViewModel with bytes and percentage
- Display global progress bar above tab content in MainScreen
- Show upload progress (MB uploaded/total, percentage) persistently
- Remove redundant in-tab progress indicators
- Progress stays visible when switching between Upload and Dumpstate tabs
- Matches cloud storage app UX (Google Drive, Dropbox)
2025-12-27 20:46:04 -05:00
4557d13064 Refactor upload architecture and fix Android 14+ notification delay
- Convert UploadController, FileUploadForegroundService, and UploadHistory from Java to Kotlin
- Move all upload logic from UploadController into FileUploadForegroundService for better encapsulation
- Service now directly manages ChunkedUploadManager instances instead of delegating to controller
- Add FOREGROUND_SERVICE_IMMEDIATE flag to bypass Android 14+ notification display delay (up to 10s for DATA_SYNC services)
- Add notification update logging for better debugging
- Simplify UploadController to only handle service launching and server file deletion

Fixes notification appearing late during uploads (was showing around 70% progress instead of immediately).
2025-12-27 20:28:16 -05:00
4ac3114064 Add integrated onboarding carousel with permissions and server setup
Replaces separate welcome and server setup screens with a unified 5-page
onboarding carousel that guides users through:
- App introduction and backend requirements
- Notification permission with explanation
- All files access permission with explanation
- Server configuration (address, port, key) with QR scanning
- Completion confirmation

Also adds notification permission checks before uploads to ensure upload
progress notifications can be displayed. Users are prompted for permission
if not already granted, then upload proceeds automatically.

Changes:
- Create OnboardingScreen.kt with 5-page carousel flow
- Add onboarding completion tracking to PreferencesUtil
- Update navigation to show onboarding on first launch
- Add notification permission checks to upload tabs
- Add roundIcon attribute to manifest for better icon display
2025-12-27 19:43:49 -05:00
836c870204 Force QR scanner to portrait orientation
Override the ZXing CaptureActivity orientation in the manifest to force portrait mode, preventing the scanner from switching to landscape mode.
2025-12-27 19:22:04 -05:00
67e26a095c Obfuscate client key field as password with visibility toggle
Add password masking to the client key field for better security. Users can toggle visibility using an eye icon button to view or hide the key as needed.
2025-12-27 19:19:09 -05:00
0b3d16f974 Move QR scan button below server configuration fields
Reposition the QR code scan button to appear immediately after the server configuration fields (address, port, and client key) that it populates, improving the logical flow and making it clearer what the button does.
2025-12-27 19:17:48 -05:00
a79ccf8f00 Improve upload parameters dialog to make fields clearly editable
Add visual cues and instructions to clarify that upload parameter fields can be edited:
- Add instructional text at top of dialog
- Add edit icons to both text fields
- Update labels to include "(tap to edit)" hint
- Change placeholders to action-oriented text
- Auto-focus first field to show keyboard immediately
- Add keyboard actions for better navigation
2025-12-27 19:17:04 -05:00
e60cdf9355 Add configurable upload parameter defaults with optional prompt
Users can now set default upload parameters (max downloads and expiry hours) in settings and choose whether to show the upload parameters dialog or use saved defaults directly.

Changes:
- Add upload preferences: default max downloads, default expiry hours, and ask before upload toggle
- Update SettingsScreen with new upload defaults section
- Modify UploadParamsDialog to initialize with saved defaults instead of hardcoded values
- Update DumpstateTab and GeneralUploadTab to check askBeforeUpload preference and bypass dialog when disabled
2025-12-27 19:14:24 -05:00
d9052454fe Fix storage permission flow to direct user to settings
- Replace READ_EXTERNAL_STORAGE permission with all files access check
- Open app settings when permission is needed so user can grant it
- Check permission status when returning from settings
- Launch file picker automatically if permission is granted
2025-12-27 18:58:58 -05:00
4a7a48c9a4 Remove legacy XML UI code after Compose migration 2025-12-27 18:55:54 -05:00
2c8315cb5d Migrate UI to Jetpack Compose with Material 3 design
- Replace XML layouts with Compose screens for all 5 Activities
- Implement Material 3 theme with Google Blue/Green color scheme matching app icon
- Add device-aware navigation with bottom tabs for Samsung devices
- Remove action bar for modern edge-to-edge UI design
- Update Kotlin to 1.9.22 for Compose Compiler compatibility
- Add Compose dependencies and configure build system
- Implement MainViewModel for upload state management
- Create reusable dialog components for upload parameters and results
- Preserve all existing Java controllers and services (UI-only migration)
2025-12-27 18:47:29 -05:00
58 changed files with 6557 additions and 5390 deletions

View File

@@ -39,6 +39,14 @@ android {
languageVersion.set(JavaLanguageVersion.of(17)) languageVersion.set(JavaLanguageVersion.of(17))
} }
} }
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.5.8'
}
} }
dependencies { dependencies {
@@ -77,6 +85,23 @@ dependencies {
// Coroutines // Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" 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 // Testing
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1'

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.mattintech.simplelogupload"> package="com.mattintech.simplelogupload">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -14,6 +15,7 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_app_icon" android:icon="@drawable/ic_app_icon"
android:roundIcon="@drawable/ic_app_icon"
android:label="SimpleLogUpload" android:label="SimpleLogUpload"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
@@ -22,22 +24,25 @@
android:usesCleartextTraffic="false" android:usesCleartextTraffic="false"
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar"> android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
<!-- Main Activity --> <!-- NEW: Compose Main Activity (LAUNCHER) -->
<activity <activity
android:name=".view.MainActivity" android:name=".ComposeMainActivity"
android:exported="true"> android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<!-- LAUNCHER - App starts here -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Share intent filter for files --> <!-- Share intent filter for files -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </intent-filter>
<!-- Share intent filter for multiple files --> <!-- Share intent filter for multiple files -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" /> <action android:name="android.intent.action.SEND_MULTIPLE" />
@@ -45,45 +50,15 @@
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Upload History Activity --> <!-- QR Code Scanner Activity -->
<activity <activity
android:name=".view.UploadHistoryActivity" android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:label="Upload History" android:screenOrientation="portrait"
android:parentActivityName=".view.MainActivity" android:stateNotNeeded="true"
android:exported="false"> android:theme="@style/zxing_CaptureTheme"
<meta-data android:windowSoftInputMode="stateAlwaysHidden"
android:name="android.support.PARENT_ACTIVITY" tools:replace="screenOrientation" />
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>
<!-- File Upload Foreground Service --> <!-- File Upload Foreground Service -->
<service <service

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,26 @@ package com.mattintech.simplelogupload.constant;
public class AppConstants { public class AppConstants {
public static final String APP_TAG = "SLU::"; 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_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR"; 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 // Intent extras
public static final String EXTRA_UPLOAD_CODE = "upload_code"; public static final String EXTRA_UPLOAD_CODE = "upload_code";
public static final String EXTRA_UPLOAD_ERROR = "upload_error"; 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 // File patterns
public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip"; public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip";

View File

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

View File

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

View File

@@ -1,380 +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.upload.ChunkedUploadManager;
import com.mattintech.simplelogupload.upload.UploadStrategySelector;
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);
// 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);
}
}
/**
* 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;
}
// Determine whether to use chunked upload or standard upload
boolean useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(context, file);
String networkType = UploadStrategySelector.getNetworkTypeName(context);
Log.d(TAG, "Uploading file: " + file.getName() + ", Size: " + file.length() +
", Network: " + networkType + ", Chunked: " + useChunkedUpload);
if (useChunkedUpload) {
// Use chunked upload for large files
return performChunkedUpload(file, maxDownloads, expiryHours);
} else {
// Use standard upload for smaller files
return performStandardUpload(file, maxDownloads, expiryHours);
}
}
/**
* Perform a chunked upload for large files
*
* @param file The file to upload
* @param maxDownloads Maximum number of downloads
* @param expiryHours Expiry time in hours
* @return CompletableFuture containing the upload result
*/
private CompletableFuture<UploadResult> performChunkedUpload(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> future = new CompletableFuture<>();
// Create chunked upload manager
ChunkedUploadManager uploadManager = new ChunkedUploadManager(
context, file, maxDownloads, expiryHours);
// Set progress listener if needed
uploadManager.setProgressListener(new ChunkedUploadManager.UploadProgressListener() {
@Override
public void onProgress(long bytesUploaded, long totalBytes, int percentComplete) {
// This could be used to update UI or notify a service
Log.d(TAG, "Chunked upload progress: " + percentComplete + "%");
}
@Override
public void onChunkComplete(int chunkIndex, int totalChunks) {
Log.d(TAG, "Chunk " + chunkIndex + "/" + totalChunks + " complete");
}
@Override
public void onUploadComplete(UploadResult result) {
Log.d(TAG, "Chunked upload complete: " + result);
// Save to history
if (result.isSuccess()) {
saveToHistory(result);
}
future.complete(result);
}
@Override
public void onError(String error) {
Log.e(TAG, "Chunked upload error: " + error);
future.complete(new UploadResult(error));
}
@Override
public void onPaused() {
Log.d(TAG, "Chunked upload paused");
}
@Override
public void onResumed() {
Log.d(TAG, "Chunked upload resumed");
}
});
// Start the upload
uploadManager.startUpload()
.thenAccept(result -> {
// This is handled by the listener, but we need to catch any exceptions
})
.exceptionally(ex -> {
Log.e(TAG, "Exception during chunked upload", ex);
future.complete(new UploadResult("Exception during upload: " + ex.getMessage()));
return null;
});
return future;
}
/**
* Perform a standard (non-chunked) upload for smaller files
*
* @param file The file to upload
* @param maxDownloads Maximum number of downloads
* @param expiryHours Expiry time in hours
* @return CompletableFuture containing the upload result
*/
private CompletableFuture<UploadResult> performStandardUpload(
File file, int maxDownloads, int expiryHours) {
CompletableFuture<UploadResult> future = new CompletableFuture<>();
// 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, "Using standard upload for file: " + file.getName());
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: " + "********");
// 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 + ": ********");
} 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,273 +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.upload.UploadStrategySelector;
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 (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");
int dataSync = 1; // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC value is 1
startForeground(NOTIFICATION_ID, createInitialNotification(), dataSync);
} catch (Exception e) {
Log.e(TAG, "Error starting foreground service with type", e);
stopSelf();
return START_NOT_STICKY;
}
} 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() + "...");
// Log network information
String networkType = UploadStrategySelector.getNetworkTypeName(this);
boolean isWifi = UploadStrategySelector.isWifiConnection(this);
boolean useChunked = UploadStrategySelector.shouldUseChunkedUpload(this, file);
Log.d(TAG, "Starting upload - Network: " + networkType + ", Chunked: " + useChunked);
// Update notification with network info
updateNotification("Uploading on " + networkType +
(useChunked ? " (chunked)" : "") + "...");
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,10 @@ import java.io.File;
public class UploadStrategySelector { public class UploadStrategySelector {
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy"; private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
// Threshold for using chunked uploads // Threshold for using chunked uploads - matches chunk sizes
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi // Only use chunked upload if file is larger than chunk size
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile 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 * Check if chunked uploads should be used for the given file

View File

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

View File

@@ -14,12 +14,24 @@ public class PreferencesUtil {
public static final String KEY_SERVER_PORT = "server_port"; public static final String KEY_SERVER_PORT = "server_port";
public static final String KEY_CLIENT_KEY = "client_key"; public static final String KEY_CLIENT_KEY = "client_key";
public static final String KEY_SETUP_COMPLETED = "setup_completed"; 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 // Default values
private static final String DEFAULT_SERVER_ADDRESS = ""; private static final String DEFAULT_SERVER_ADDRESS = "";
private static final int DEFAULT_SERVER_PORT = 0; private static final int DEFAULT_SERVER_PORT = 0;
private static final String DEFAULT_CLIENT_KEY = ""; private static final String DEFAULT_CLIENT_KEY = "";
private static final boolean DEFAULT_SETUP_COMPLETED = false; 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 * Get the shared preferences instance
@@ -117,7 +129,7 @@ public class PreferencesUtil {
/** /**
* Reset all settings to default values * Reset all settings to default values
* *
* @param context The application context * @param context The application context
*/ */
public static void resetToDefaults(Context context) { public static void resetToDefaults(Context context) {
@@ -126,6 +138,11 @@ public class PreferencesUtil {
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT); editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY); editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED); 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(); editor.apply();
} }
@@ -141,17 +158,97 @@ public class PreferencesUtil {
/** /**
* Set the setup completed status * Set the setup completed status
* *
* @param context The application context * @param context The application context
* @param completed True if setup has been completed, false otherwise * @param completed True if setup has been completed, false otherwise
*/ */
public static void setSetupCompleted(Context context, boolean completed) { public static void setSetupCompleted(Context context, boolean completed) {
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply(); 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 * Check if all required server settings are configured
* *
* @param context The application context * @param context The application context
* @return True if all required settings are configured, false otherwise * @return True if all required settings are configured, false otherwise
*/ */
@@ -160,7 +257,65 @@ public class PreferencesUtil {
String address = prefs.getString(KEY_SERVER_ADDRESS, DEFAULT_SERVER_ADDRESS); String address = prefs.getString(KEY_SERVER_ADDRESS, DEFAULT_SERVER_ADDRESS);
int port = prefs.getInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT); int port = prefs.getInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
String clientKey = prefs.getString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY); String clientKey = prefs.getString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
return !address.isEmpty() && port > 0 && !clientKey.isEmpty(); 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();
}
} }

View File

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

View 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
}
}
}

View File

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

View File

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

View File

@@ -1,206 +0,0 @@
package com.mattintech.simplelogupload.view;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Button;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.textfield.TextInputEditText;
import com.journeyapps.barcodescanner.ScanContract;
import com.journeyapps.barcodescanner.ScanOptions;
import com.journeyapps.barcodescanner.ScanIntentResult;
import com.mattintech.simplelogupload.R;
import com.mattintech.simplelogupload.constant.AppConstants;
import com.mattintech.simplelogupload.model.QrServerConfig;
import com.mattintech.simplelogupload.util.PermissionUtil;
import com.mattintech.simplelogupload.util.PreferencesUtil;
import com.mattintech.simplelogupload.util.QrScannerUtil;
/**
* 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;
private ActivityResultLauncher<ScanOptions> qrScannerLauncher;
private Button scanQrButton;
@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);
scanQrButton = findViewById(R.id.scanQrButton);
// Initialize QR scanner launcher
qrScannerLauncher = registerForActivityResult(
new ScanContract(),
result -> handleQrScanResult(result)
);
// Set up QR scan button click listener
scanQrButton.setOnClickListener(v -> initiateQrScan());
// 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();
}
}
/**
* Initiate QR code scan
*/
private void initiateQrScan() {
// Check camera permission
if (!PermissionUtil.hasCameraPermission(this)) {
PermissionUtil.requestCameraPermission(this);
return;
}
// Launch scanner
ScanOptions options = QrScannerUtil.createScanOptions();
qrScannerLauncher.launch(options);
}
/**
* Handle QR scan result
*
* @param result The scan result
*/
private void handleQrScanResult(ScanIntentResult result) {
QrScannerUtil.processScanResult(
result.getContents(),
new QrScannerUtil.QrScanCallback() {
@Override
public void onQrScanned(QrServerConfig config) {
// Auto-fill the input fields
serverAddressInput.setText(config.getServer());
serverPortInput.setText(String.valueOf(config.getPort()));
clientKeyInput.setText(config.getKey());
Toast.makeText(ServerSetupActivity.this,
R.string.qr_scan_success,
Toast.LENGTH_SHORT).show();
Log.d(TAG, "QR code scanned successfully");
}
@Override
public void onQrScanCancelled() {
Toast.makeText(ServerSetupActivity.this,
R.string.qr_scan_cancelled,
Toast.LENGTH_SHORT).show();
}
@Override
public void onQrScanError(String errorMessage) {
Toast.makeText(ServerSetupActivity.this,
getString(R.string.qr_scan_error, errorMessage),
Toast.LENGTH_LONG).show();
Log.e(TAG, "QR scan error: " + errorMessage);
}
}
);
}
/**
* Handle permission request results
*/
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionUtil.REQUEST_CAMERA_PERMISSION) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted, initiate scan
initiateQrScan();
} else {
// Permission denied
Toast.makeText(this, R.string.camera_permission_denied, Toast.LENGTH_LONG).show();
}
}
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
}

View File

@@ -1,230 +0,0 @@
package com.mattintech.simplelogupload.view;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Button;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.textfield.TextInputEditText;
import com.journeyapps.barcodescanner.ScanContract;
import com.journeyapps.barcodescanner.ScanOptions;
import com.journeyapps.barcodescanner.ScanIntentResult;
import com.mattintech.simplelogupload.R;
import com.mattintech.simplelogupload.constant.AppConstants;
import com.mattintech.simplelogupload.model.QrServerConfig;
import com.mattintech.simplelogupload.util.PermissionUtil;
import com.mattintech.simplelogupload.util.PreferencesUtil;
import com.mattintech.simplelogupload.util.QrScannerUtil;
/**
* 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;
private ActivityResultLauncher<ScanOptions> qrScannerLauncher;
private Button scanQrButton;
@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);
scanQrButton = findViewById(R.id.scanQrButton);
// Initialize QR scanner launcher
qrScannerLauncher = registerForActivityResult(
new ScanContract(),
result -> handleQrScanResult(result)
);
// Set up QR scan button click listener
scanQrButton.setOnClickListener(v -> initiateQrScan());
// 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();
}
/**
* Initiate QR code scan
*/
private void initiateQrScan() {
// Check camera permission
if (!PermissionUtil.hasCameraPermission(this)) {
PermissionUtil.requestCameraPermission(this);
return;
}
// Launch scanner
ScanOptions options = QrScannerUtil.createScanOptions();
qrScannerLauncher.launch(options);
}
/**
* Handle QR scan result
*
* @param result The scan result
*/
private void handleQrScanResult(ScanIntentResult result) {
QrScannerUtil.processScanResult(
result.getContents(),
new QrScannerUtil.QrScanCallback() {
@Override
public void onQrScanned(QrServerConfig config) {
// Auto-fill the input fields
serverAddressInput.setText(config.getServer());
serverPortInput.setText(String.valueOf(config.getPort()));
clientKeyInput.setText(config.getKey());
Toast.makeText(SettingsActivity.this,
R.string.qr_scan_success,
Toast.LENGTH_SHORT).show();
Log.d(TAG, "QR code scanned successfully");
}
@Override
public void onQrScanCancelled() {
Toast.makeText(SettingsActivity.this,
R.string.qr_scan_cancelled,
Toast.LENGTH_SHORT).show();
}
@Override
public void onQrScanError(String errorMessage) {
Toast.makeText(SettingsActivity.this,
getString(R.string.qr_scan_error, errorMessage),
Toast.LENGTH_LONG).show();
Log.e(TAG, "QR scan error: " + errorMessage);
}
}
);
}
/**
* Handle permission request results
*/
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionUtil.REQUEST_CAMERA_PERMISSION) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted, initiate scan
initiateQrScan();
} else {
// Permission denied
Toast.makeText(this, R.string.camera_permission_denied, Toast.LENGTH_LONG).show();
}
}
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,134 +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>
<!-- QR Code Scan Button -->
<Button
android:id="@+id/scanQrButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/scan_qr_code_button"
android:drawableLeft="@drawable/ic_qr_scan"
android:drawablePadding="8dp"
android:paddingVertical="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/scan_qr_code_hint"
android:textAlignment="center"
android:textSize="12sp"
android:textStyle="italic" />
<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>

View File

@@ -1,98 +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>
<!-- QR Code Scan Button -->
<Button
android:id="@+id/scanQrButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:text="@string/scan_qr_code_button"
android:drawableLeft="@drawable/ic_qr_scan"
android:drawablePadding="8dp" />
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x ext.kotlin_version = '1.9.22' // Updated for Compose Compiler 1.5.8 compatibility
repositories { repositories {
google() google()
mavenCentral() mavenCentral()