Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87764cc15b | |||
| eff3478a97 | |||
| 04b3bea454 | |||
| 41363028aa | |||
| d40814ec4c | |||
| c80d523972 | |||
| e7335977c0 | |||
| 4557d13064 | |||
| 4ac3114064 | |||
| 836c870204 | |||
| 67e26a095c | |||
| 0b3d16f974 | |||
| a79ccf8f00 | |||
| e60cdf9355 | |||
| d9052454fe | |||
| 4a7a48c9a4 | |||
| 2c8315cb5d |
@@ -39,6 +39,14 @@ android {
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.5.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -77,6 +85,23 @@ dependencies {
|
||||
// Coroutines
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
|
||||
// Jetpack Compose
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.activity:activity-compose:1.9.3'
|
||||
implementation 'androidx.navigation:navigation-compose:2.8.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata'
|
||||
|
||||
// Compose Debug
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
|
||||
// Testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.mattintech.simplelogupload">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -14,6 +15,7 @@
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_app_icon"
|
||||
android:roundIcon="@drawable/ic_app_icon"
|
||||
android:label="SimpleLogUpload"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
@@ -22,10 +24,13 @@
|
||||
android:usesCleartextTraffic="false"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<!-- NEW: Compose Main Activity (LAUNCHER) -->
|
||||
<activity
|
||||
android:name=".view.MainActivity"
|
||||
android:exported="true">
|
||||
android:name=".ComposeMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
|
||||
<!-- LAUNCHER - App starts here -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -46,44 +51,14 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Upload History Activity -->
|
||||
<!-- QR Code Scanner Activity -->
|
||||
<activity
|
||||
android:name=".view.UploadHistoryActivity"
|
||||
android:label="Upload History"
|
||||
android:parentActivityName=".view.MainActivity"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<!-- Settings Activity -->
|
||||
<activity
|
||||
android:name=".view.SettingsActivity"
|
||||
android:label="Settings"
|
||||
android:parentActivityName=".view.MainActivity"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<!-- Welcome Activity -->
|
||||
<activity
|
||||
android:name=".view.WelcomeActivity"
|
||||
android:label="Welcome"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
|
||||
|
||||
<!-- Server Setup Activity -->
|
||||
<activity
|
||||
android:name=".view.ServerSetupActivity"
|
||||
android:label="Server Setup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value=".view.WelcomeActivity" />
|
||||
</activity>
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<!-- File Upload Foreground Service -->
|
||||
<service
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.mattintech.simplelogupload
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.mattintech.simplelogupload.navigation.AppNavigation
|
||||
import com.mattintech.simplelogupload.navigation.Screen
|
||||
import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
|
||||
/**
|
||||
* Main Activity for the Compose-based UI
|
||||
* Replaces all previous XML-based Activities with a single Compose Activity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Enable edge-to-edge display for modern UI
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Determine start destination based on setup status
|
||||
val startDestination = getStartDestination()
|
||||
|
||||
// Handle share intents if this was launched from external app
|
||||
val sharedFileUri = handleShareIntent(intent)
|
||||
|
||||
setContent {
|
||||
SimpleLogUploadTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle new share intents when activity is already running
|
||||
handleShareIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which screen to show first based on app configuration
|
||||
*/
|
||||
private fun getStartDestination(): String {
|
||||
// Check if onboarding has been completed first
|
||||
if (!PreferencesUtil.isOnboardingCompleted(this)) {
|
||||
return Screen.Onboarding.route
|
||||
}
|
||||
|
||||
// Then check if setup is complete
|
||||
return if (PreferencesUtil.isSetupCompleted(this) &&
|
||||
PreferencesUtil.areServerSettingsConfigured(this)) {
|
||||
Screen.Main.route
|
||||
} else {
|
||||
Screen.Welcome.route
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle share intents from external apps
|
||||
*
|
||||
* @param intent The intent that launched this activity
|
||||
* @return Uri of shared file, or null if not a share intent
|
||||
*/
|
||||
private fun handleShareIntent(intent: Intent?): Uri? {
|
||||
if (intent?.action == Intent.ACTION_SEND) {
|
||||
if (intent.type?.startsWith("text/") == true) {
|
||||
// Handle shared text
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
// TODO: Handle shared text if needed
|
||||
return null
|
||||
} else {
|
||||
// Handle shared file
|
||||
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Pass this URI to MainScreen/MainViewModel for upload
|
||||
return sharedUri
|
||||
}
|
||||
} else if (intent?.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
// Handle multiple shared files
|
||||
val sharedUris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Handle multiple files
|
||||
return sharedUris?.firstOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package com.mattintech.simplelogupload.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.format.Formatter;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Adapter for displaying dumpstate files in a RecyclerView with multi-selection capability
|
||||
*/
|
||||
public class DumpstateAdapter extends RecyclerView.Adapter<DumpstateAdapter.ViewHolder> {
|
||||
|
||||
private final List<File> files;
|
||||
private final Context context;
|
||||
private final Set<Integer> selectedPositions = new HashSet<>();
|
||||
private OnSelectionChangedListener selectionChangedListener;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param files List of dumpstate files
|
||||
*/
|
||||
public DumpstateAdapter(Context context, List<File> files) {
|
||||
this.context = context;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for selection changes
|
||||
*
|
||||
* @param listener The listener
|
||||
*/
|
||||
public void setOnSelectionChangedListener(OnSelectionChangedListener listener) {
|
||||
this.selectionChangedListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of selected files
|
||||
*
|
||||
* @return List of selected files
|
||||
*/
|
||||
public List<File> getSelectedFiles() {
|
||||
List<File> selectedFiles = new ArrayList<>();
|
||||
for (int position : selectedPositions) {
|
||||
if (position >= 0 && position < files.size()) {
|
||||
selectedFiles.add(files.get(position));
|
||||
}
|
||||
}
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item is selected
|
||||
*
|
||||
* @param position The position to check
|
||||
* @return true if selected, false otherwise
|
||||
*/
|
||||
public boolean isSelected(int position) {
|
||||
return selectedPositions.contains(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select or deselect an item
|
||||
*
|
||||
* @param position The position to select/deselect
|
||||
* @param selected Whether to select (true) or deselect (false)
|
||||
*/
|
||||
public void setSelected(int position, boolean selected) {
|
||||
if (selected) {
|
||||
selectedPositions.add(position);
|
||||
} else {
|
||||
selectedPositions.remove(position);
|
||||
}
|
||||
notifyItemChanged(position);
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(selectedPositions.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle selection state of an item
|
||||
*
|
||||
* @param position The position to toggle
|
||||
*/
|
||||
public void toggleSelection(int position) {
|
||||
setSelected(position, !isSelected(position));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all items
|
||||
*/
|
||||
public void selectAll() {
|
||||
selectedPositions.clear();
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
selectedPositions.add(i);
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(selectedPositions.size());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selections
|
||||
*/
|
||||
public void clearSelections() {
|
||||
selectedPositions.clear();
|
||||
notifyDataSetChanged();
|
||||
|
||||
if (selectionChangedListener != null) {
|
||||
selectionChangedListener.onSelectionChanged(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of selected items
|
||||
*
|
||||
* @return The count of selected items
|
||||
*/
|
||||
public int getSelectedCount() {
|
||||
return selectedPositions.size();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_dumpstate_file, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
File file = files.get(position);
|
||||
|
||||
// Set file name
|
||||
holder.fileNameText.setText(file.getName());
|
||||
|
||||
// Add tooltip for long filenames
|
||||
holder.fileNameText.setOnLongClickListener(v -> {
|
||||
Toast.makeText(context, file.getName(), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Format and set file date
|
||||
Date lastModified = new Date(file.lastModified());
|
||||
String formattedDate = DateFormat.format("MMM dd, yyyy hh:mm a", lastModified).toString();
|
||||
holder.fileDateText.setText(context.getString(R.string.dumpstate_date_format, formattedDate));
|
||||
|
||||
// Format and set file size
|
||||
String fileSize = Formatter.formatFileSize(context, file.length());
|
||||
holder.fileSizeText.setText(context.getString(R.string.dumpstate_size_format, fileSize));
|
||||
|
||||
// Set checkbox state
|
||||
holder.checkBox.setChecked(isSelected(position));
|
||||
|
||||
// Set click listeners
|
||||
holder.checkBox.setOnClickListener(v -> toggleSelection(holder.getAbsoluteAdapterPosition()));
|
||||
|
||||
holder.itemView.setOnClickListener(v -> toggleSelection(holder.getAbsoluteAdapterPosition()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for dumpstate file items
|
||||
*/
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
final CheckBox checkBox;
|
||||
final TextView fileNameText;
|
||||
final TextView fileDateText;
|
||||
final TextView fileSizeText;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
checkBox = itemView.findViewById(R.id.checkBox);
|
||||
fileNameText = itemView.findViewById(R.id.fileNameText);
|
||||
fileDateText = itemView.findViewById(R.id.fileDateText);
|
||||
fileSizeText = itemView.findViewById(R.id.fileSizeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handling selection changes
|
||||
*/
|
||||
public interface OnSelectionChangedListener {
|
||||
void onSelectionChanged(int selectedCount);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.mattintech.simplelogupload.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.format.DateFormat;
|
||||
import android.text.format.Formatter;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Adapter for displaying dumpstate files in a RecyclerView
|
||||
*/
|
||||
public class DumpstateFileAdapter extends RecyclerView.Adapter<DumpstateFileAdapter.ViewHolder> {
|
||||
|
||||
private final List<File> files;
|
||||
private final Context context;
|
||||
private int selectedPosition = -1;
|
||||
private OnItemSelectedListener listener;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param files List of dumpstate files
|
||||
*/
|
||||
public DumpstateFileAdapter(Context context, List<File> files) {
|
||||
this.context = context;
|
||||
this.files = files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for item selection
|
||||
*
|
||||
* @param listener The listener
|
||||
*/
|
||||
public void setOnItemSelectedListener(OnItemSelectedListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected file
|
||||
*
|
||||
* @return The selected file or null if none selected
|
||||
*/
|
||||
public File getSelectedFile() {
|
||||
if (selectedPosition >= 0 && selectedPosition < files.size()) {
|
||||
return files.get(selectedPosition);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_dumpstate_file, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
File file = files.get(position);
|
||||
|
||||
// Set file name
|
||||
holder.fileNameText.setText(file.getName());
|
||||
|
||||
// Add tooltip for long filenames
|
||||
holder.fileNameText.setOnLongClickListener(v -> {
|
||||
Toast.makeText(context, file.getName(), Toast.LENGTH_LONG).show();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Format and set file date
|
||||
Date lastModified = new Date(file.lastModified());
|
||||
String formattedDate = DateFormat.format("MMM dd, yyyy hh:mm a", lastModified).toString();
|
||||
holder.fileDateText.setText(context.getString(R.string.dumpstate_date_format, formattedDate));
|
||||
|
||||
// Format and set file size
|
||||
String fileSize = Formatter.formatFileSize(context, file.length());
|
||||
holder.fileSizeText.setText(context.getString(R.string.dumpstate_size_format, fileSize));
|
||||
|
||||
// Set checkbox state
|
||||
holder.checkBox.setChecked(position == selectedPosition);
|
||||
|
||||
// Set click listener for the whole item
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
int previousSelected = selectedPosition;
|
||||
selectedPosition = holder.getAdapterPosition();
|
||||
|
||||
// Update previous selected item
|
||||
if (previousSelected >= 0) {
|
||||
notifyItemChanged(previousSelected);
|
||||
}
|
||||
|
||||
// Update newly selected item
|
||||
notifyItemChanged(selectedPosition);
|
||||
|
||||
// Notify listener
|
||||
if (listener != null) {
|
||||
listener.onItemSelected(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for dumpstate file items
|
||||
*/
|
||||
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
final CheckBox checkBox;
|
||||
final TextView fileNameText;
|
||||
final TextView fileDateText;
|
||||
final TextView fileSizeText;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
checkBox = itemView.findViewById(R.id.checkBox);
|
||||
fileNameText = itemView.findViewById(R.id.fileNameText);
|
||||
fileDateText = itemView.findViewById(R.id.fileDateText);
|
||||
fileSizeText = itemView.findViewById(R.id.fileSizeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handling item selection
|
||||
*/
|
||||
public interface OnItemSelectedListener {
|
||||
void onItemSelected(File file);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,26 @@ package com.mattintech.simplelogupload.constant;
|
||||
public class AppConstants {
|
||||
public static final String APP_TAG = "SLU::";
|
||||
|
||||
// Broadcast actions
|
||||
// Broadcast actions (service -> UI)
|
||||
public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
|
||||
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR";
|
||||
public static final String ACTION_UPLOAD_PROGRESS = "com.mattintech.simplelogupload.UPLOAD_PROGRESS";
|
||||
public static final String ACTION_ZIP_PROGRESS = "com.mattintech.simplelogupload.ZIP_PROGRESS";
|
||||
public static final String ACTION_UPLOAD_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED";
|
||||
public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED";
|
||||
public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED";
|
||||
|
||||
// Intent actions (UI/notification -> service)
|
||||
public static final String ACTION_PAUSE_UPLOAD = "com.mattintech.simplelogupload.PAUSE_UPLOAD";
|
||||
public static final String ACTION_RESUME_UPLOAD = "com.mattintech.simplelogupload.RESUME_UPLOAD";
|
||||
public static final String ACTION_CANCEL_UPLOAD = "com.mattintech.simplelogupload.CANCEL_UPLOAD";
|
||||
|
||||
// Intent extras
|
||||
public static final String EXTRA_UPLOAD_CODE = "upload_code";
|
||||
public static final String EXTRA_UPLOAD_ERROR = "upload_error";
|
||||
public static final String EXTRA_BYTES_UPLOADED = "bytes_uploaded";
|
||||
public static final String EXTRA_TOTAL_BYTES = "total_bytes";
|
||||
public static final String EXTRA_PERCENT_COMPLETE = "percent_complete";
|
||||
|
||||
// File patterns
|
||||
public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip";
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
package com.mattintech.simplelogupload.controller;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.constant.FileConstants;
|
||||
import com.mattintech.simplelogupload.util.FileUtil;
|
||||
import com.mattintech.simplelogupload.util.ZipUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Controller for file operations
|
||||
*/
|
||||
public class FileController {
|
||||
private static final String TAG = AppConstants.APP_TAG + "FileController";
|
||||
|
||||
/**
|
||||
* Interface for file preparation callbacks
|
||||
*/
|
||||
public interface FilePrepareCallback {
|
||||
void onSuccess(File preparedFile);
|
||||
void onProgress(int progress);
|
||||
void onError(Exception e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent dumpstate file
|
||||
*
|
||||
* @return The most recent dumpstate file or null if none found
|
||||
*/
|
||||
public static File getMostRecentDumpStateFile() {
|
||||
return FileUtil.findMostRecentDumpStateFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all dumpstate files
|
||||
*
|
||||
* @return Array of dumpstate files
|
||||
*/
|
||||
public static File[] getAllDumpStateFiles() {
|
||||
return FileUtil.findAllDumpStateFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload - ensures the file is zipped
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
public static void prepareFileForUpload(File file, FilePrepareCallback callback) {
|
||||
if (file == null || !file.exists()) {
|
||||
callback.onError(new IOException("File does not exist"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
callback.onError(new IOException("File size exceeds limit of " +
|
||||
FileConstants.MAX_FILE_SIZE + " bytes"));
|
||||
return;
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(file)) {
|
||||
callback.onSuccess(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, compress it
|
||||
try {
|
||||
ZipUtil.zipFile(file, new ZipUtil.ZipProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesProcessed, long totalBytes) {
|
||||
int progress = (int) ((bytesProcessed * 100) / totalBytes);
|
||||
callback.onProgress(progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete(File zipFile) {
|
||||
callback.onSuccess(zipFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception e) {
|
||||
callback.onError(e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error preparing file for upload", e);
|
||||
callback.onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload by zipping them into a single file
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
public static void prepareMultipleFilesForUpload(List<File> files, Context context, FilePrepareCallback callback) {
|
||||
if (files == null || files.isEmpty()) {
|
||||
callback.onError(new IOException("No files provided"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION;
|
||||
|
||||
try {
|
||||
// Use cache directory for temporary zip storage
|
||||
ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, new ZipUtil.ZipProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesProcessed, long totalBytes) {
|
||||
int progress = (int) ((bytesProcessed * 100) / Math.max(totalBytes, 1)); // Avoid division by zero
|
||||
callback.onProgress(progress);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete(File zipFile) {
|
||||
callback.onSuccess(zipFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception e) {
|
||||
callback.onError(e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e);
|
||||
callback.onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload asynchronously
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
public static CompletableFuture<File> prepareFileForUploadAsync(File file) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
if (file == null || !file.exists()) {
|
||||
throw new IOException("File does not exist");
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
throw new IOException("File size exceeds limit of " +
|
||||
FileConstants.MAX_FILE_SIZE + " bytes");
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(file)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
// Otherwise, compress it
|
||||
return ZipUtil.zipFile(file);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error preparing file for upload", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, Executors.newSingleThreadExecutor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload asynchronously
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
public static CompletableFuture<File> prepareMultipleFilesForUploadAsync(List<File> files, Context context) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IOException("No files provided");
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
String zipFileName = "multiple_files_" + System.currentTimeMillis() + FileConstants.ZIP_EXTENSION;
|
||||
|
||||
// Use cache directory for temporary zip storage
|
||||
return ZipUtil.zipFiles(files, context.getCacheDir(), zipFileName, null);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, Executors.newSingleThreadExecutor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a content URI to a temporary file
|
||||
*
|
||||
* @param context The context
|
||||
* @param uri The content URI
|
||||
* @param filename The desired filename
|
||||
* @return The saved file
|
||||
* @throws IOException If saving fails
|
||||
*/
|
||||
public static File saveContentUriToFile(Context context, Uri uri, String filename) throws IOException {
|
||||
if (context == null || uri == null) {
|
||||
throw new IOException("Invalid context or URI");
|
||||
}
|
||||
|
||||
// Create a temporary file
|
||||
File outputFile = new File(context.getCacheDir(), filename);
|
||||
|
||||
try (InputStream inputStream = context.getContentResolver().openInputStream(uri);
|
||||
FileOutputStream outputStream = new FileOutputStream(outputFile)) {
|
||||
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Could not open input stream for URI: " + uri);
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file size is within the allowable limits
|
||||
*
|
||||
* @param file The file to validate
|
||||
* @return true if the file size is valid, false otherwise
|
||||
*/
|
||||
public static boolean isValidFileSize(File file) {
|
||||
return FileUtil.isValidFileSize(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a content URI to a temporary file asynchronously
|
||||
*
|
||||
* @param context The context
|
||||
* @param uri The content URI
|
||||
* @param filename The desired filename
|
||||
* @return CompletableFuture containing the saved file
|
||||
*/
|
||||
public static CompletableFuture<File> saveContentUriToFileAsync(
|
||||
Context context, Uri uri, String filename) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return saveContentUriToFile(context, uri, filename);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error saving content URI to file", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}, Executors.newSingleThreadExecutor());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.mattintech.simplelogupload.controller
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.FileConstants
|
||||
import com.mattintech.simplelogupload.util.FileUtil
|
||||
import com.mattintech.simplelogupload.util.ZipUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Controller for file operations using Kotlin coroutines
|
||||
*/
|
||||
object FileController {
|
||||
private const val TAG = AppConstants.APP_TAG + "FileController"
|
||||
|
||||
// Track current zip job for cancellation
|
||||
private var currentZipJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Interface for file preparation callbacks
|
||||
*/
|
||||
interface FilePrepareCallback {
|
||||
fun onSuccess(preparedFile: File)
|
||||
fun onProgress(progress: Int)
|
||||
fun onError(e: Exception)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent dumpstate file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getMostRecentDumpStateFile(): File? {
|
||||
return FileUtil.findMostRecentDumpStateFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all dumpstate files
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getAllDumpStateFiles(): Array<File> {
|
||||
return FileUtil.findAllDumpStateFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload - ensures the file is zipped if needed
|
||||
* Runs zipping on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareFileForUpload(file: File, callback: FilePrepareCallback) {
|
||||
if (!file.exists()) {
|
||||
callback.onError(IOException("File does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
callback.onError(
|
||||
IOException("File size exceeds limit of ${FileConstants.MAX_FILE_SIZE} bytes")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(file)) {
|
||||
callback.onSuccess(file)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, compress it on a background thread
|
||||
currentZipJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
val progress = ((bytesProcessed * 100) / totalBytes).toInt()
|
||||
Log.d(TAG, "Zip progress callback: $progress%")
|
||||
// Post callback to main thread for UI updates
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
Log.d(TAG, "Zip complete callback")
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onSuccess(zipFile)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
Log.e(TAG, "Zip error callback", e)
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e: CancellationException) {
|
||||
// Cancellation is expected, just clean up
|
||||
Log.d(TAG, "File preparation cancelled")
|
||||
currentZipJob = null
|
||||
// Don't call error callback for cancellation
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing file for upload", e)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
currentZipJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload by zipping them into a single file
|
||||
* Runs zipping on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @param callback Callback for progress updates and completion
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareMultipleFilesForUpload(
|
||||
files: List<File>,
|
||||
context: Context,
|
||||
callback: FilePrepareCallback
|
||||
) {
|
||||
if (files.isEmpty()) {
|
||||
callback.onError(IOException("No files provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}"
|
||||
|
||||
// Zip files on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFiles(
|
||||
files,
|
||||
context.cacheDir,
|
||||
zipFileName,
|
||||
object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
val progress = if (totalBytes > 0) {
|
||||
((bytesProcessed * 100) / totalBytes).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Post callback to main thread for UI updates
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onSuccess(zipFile)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
// Post callback to main thread
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
callback.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a content URI to a temporary file
|
||||
*
|
||||
* @param context The context
|
||||
* @param uri The content URI
|
||||
* @param filename The desired filename
|
||||
* @return The saved file
|
||||
* @throws IOException If saving fails
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun saveContentUriToFile(context: Context, uri: Uri, filename: String): File {
|
||||
// Create a temporary file
|
||||
val outputFile = File(context.cacheDir, filename)
|
||||
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(outputFile).use { outputStream ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Could not open input stream for URI: $uri")
|
||||
|
||||
return outputFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the file size is within the allowable limits
|
||||
*
|
||||
* @param file The file to validate
|
||||
* @return true if the file size is valid, false otherwise
|
||||
*/
|
||||
@JvmStatic
|
||||
fun isValidFileSize(file: File): Boolean {
|
||||
return FileUtil.isValidFileSize(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing file preparation (zipping) operation
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancelPreparation() {
|
||||
currentZipJob?.cancel()
|
||||
currentZipJob = null
|
||||
Log.d(TAG, "Cancelled file preparation")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a file for upload asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param file The file to prepare
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareFileForUploadAsync(file: File): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
if (!file.exists()) {
|
||||
future.completeExceptionally(IOException("File does not exist"))
|
||||
return future
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!FileUtil.isValidFileSize(file)) {
|
||||
future.completeExceptionally(
|
||||
IOException("File size exceeds limit of ${FileConstants.MAX_FILE_SIZE} bytes")
|
||||
)
|
||||
return future
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(file)) {
|
||||
future.complete(file)
|
||||
return future
|
||||
}
|
||||
|
||||
// Otherwise, compress it on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
var resultFile: File? = null
|
||||
ZipUtil.zipFile(file, object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
// Progress not needed for async API
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
resultFile = zipFile
|
||||
future.complete(zipFile)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing file for upload", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files for upload asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param files The list of files to prepare
|
||||
* @param context The context
|
||||
* @return CompletableFuture containing the prepared file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun prepareMultipleFilesForUploadAsync(
|
||||
files: List<File>,
|
||||
context: Context
|
||||
): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
if (files.isEmpty()) {
|
||||
future.completeExceptionally(IOException("No files provided"))
|
||||
return future
|
||||
}
|
||||
|
||||
// Create a unique filename for the zip
|
||||
val zipFileName = "multiple_files_${System.currentTimeMillis()}${FileConstants.ZIP_EXTENSION}"
|
||||
|
||||
// Zip files on background thread
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
ZipUtil.zipFiles(
|
||||
files,
|
||||
context.cacheDir,
|
||||
zipFileName,
|
||||
object : ZipUtil.ZipProgressListener {
|
||||
override fun onProgress(bytesProcessed: Long, totalBytes: Long) {
|
||||
// Progress not needed for async API
|
||||
}
|
||||
|
||||
override fun onComplete(zipFile: File) {
|
||||
future.complete(zipFile)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing multiple files for upload", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a content URI to a temporary file asynchronously
|
||||
* Returns a CompletableFuture for Java compatibility
|
||||
*
|
||||
* @param context The context
|
||||
* @param uri The content URI
|
||||
* @param filename The desired filename
|
||||
* @return CompletableFuture containing the saved file
|
||||
*/
|
||||
@JvmStatic
|
||||
fun saveContentUriToFileAsync(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
filename: String
|
||||
): java.util.concurrent.CompletableFuture<File> {
|
||||
val future = java.util.concurrent.CompletableFuture<File>()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val file = saveContentUriToFile(context, uri, filename)
|
||||
future.complete(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving content URI to file", e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.mattintech.simplelogupload.model;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
/**
|
||||
* Entity class for upload history
|
||||
*/
|
||||
@Entity(tableName = "upload_history")
|
||||
public class UploadHistory {
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private int id;
|
||||
|
||||
@ColumnInfo(name = "file_name")
|
||||
private String fileName;
|
||||
|
||||
@ColumnInfo(name = "upload_time")
|
||||
private long uploadTime;
|
||||
|
||||
@ColumnInfo(name = "upload_code")
|
||||
private String uploadCode;
|
||||
|
||||
@ColumnInfo(name = "max_downloads")
|
||||
private int maxDownloads;
|
||||
|
||||
@ColumnInfo(name = "expiry_hours")
|
||||
private int expiryHours;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param fileName The name of the uploaded file
|
||||
* @param uploadTime The time of upload (in milliseconds)
|
||||
* @param uploadCode The server-provided upload code
|
||||
* @param maxDownloads The maximum number of downloads allowed
|
||||
* @param expiryHours The expiry time in hours
|
||||
*/
|
||||
public UploadHistory(String fileName, long uploadTime, String uploadCode, int maxDownloads, int expiryHours) {
|
||||
this.fileName = fileName;
|
||||
this.uploadTime = uploadTime;
|
||||
this.uploadCode = uploadCode;
|
||||
this.maxDownloads = maxDownloads;
|
||||
this.expiryHours = expiryHours;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public long getUploadTime() {
|
||||
return uploadTime;
|
||||
}
|
||||
|
||||
public void setUploadTime(long uploadTime) {
|
||||
this.uploadTime = uploadTime;
|
||||
}
|
||||
|
||||
public String getUploadCode() {
|
||||
return uploadCode;
|
||||
}
|
||||
|
||||
public void setUploadCode(String uploadCode) {
|
||||
this.uploadCode = uploadCode;
|
||||
}
|
||||
|
||||
public int getMaxDownloads() {
|
||||
return maxDownloads;
|
||||
}
|
||||
|
||||
public void setMaxDownloads(int maxDownloads) {
|
||||
this.maxDownloads = maxDownloads;
|
||||
}
|
||||
|
||||
public int getExpiryHours() {
|
||||
return expiryHours;
|
||||
}
|
||||
|
||||
public void setExpiryHours(int expiryHours) {
|
||||
this.expiryHours = expiryHours;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UploadHistory{" +
|
||||
"id=" + id +
|
||||
", fileName='" + fileName + '\'' +
|
||||
", uploadTime=" + uploadTime +
|
||||
", uploadCode='" + uploadCode + '\'' +
|
||||
", maxDownloads=" + maxDownloads +
|
||||
", expiryHours=" + expiryHours +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.mattintech.simplelogupload.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Entity class for upload history
|
||||
*/
|
||||
@Entity(tableName = "upload_history")
|
||||
data class UploadHistory(
|
||||
@ColumnInfo(name = "file_name")
|
||||
val fileName: String,
|
||||
|
||||
@ColumnInfo(name = "upload_time")
|
||||
val uploadTime: Long,
|
||||
|
||||
@ColumnInfo(name = "upload_code")
|
||||
val uploadCode: String,
|
||||
|
||||
@ColumnInfo(name = "max_downloads")
|
||||
val maxDownloads: Int,
|
||||
|
||||
@ColumnInfo(name = "expiry_hours")
|
||||
val expiryHours: Int
|
||||
) {
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.mattintech.simplelogupload.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.mattintech.simplelogupload.ui.screens.OnboardingScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.WelcomeScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.ServerSetupScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.MainScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.UploadHistoryScreen
|
||||
import com.mattintech.simplelogupload.ui.screens.SettingsScreen
|
||||
|
||||
/**
|
||||
* Main navigation graph for the app
|
||||
*
|
||||
* @param navController Navigation controller
|
||||
* @param startDestination Initial screen to show
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
startDestination: String
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
// Onboarding carousel - shown on very first launch with permissions and server setup
|
||||
composable(Screen.Onboarding.route) {
|
||||
OnboardingScreen(
|
||||
onOnboardingComplete = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
// Clear backstack so user can't go back to onboarding
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Welcome screen - shown on first launch
|
||||
composable(Screen.Welcome.route) {
|
||||
WelcomeScreen(
|
||||
onNavigateToSetup = {
|
||||
navController.navigate(Screen.ServerSetup.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Server setup screen - configure server connection
|
||||
composable(Screen.ServerSetup.route) {
|
||||
ServerSetupScreen(
|
||||
onSetupComplete = {
|
||||
navController.navigate(Screen.Main.route) {
|
||||
// Clear backstack so user can't go back to setup
|
||||
popUpTo(Screen.Welcome.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main screen - primary upload functionality
|
||||
composable(Screen.Main.route) {
|
||||
MainScreen(
|
||||
onNavigateToHistory = {
|
||||
navController.navigate(Screen.UploadHistory.route)
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate(Screen.Settings.route)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload history screen
|
||||
composable(Screen.UploadHistory.route) {
|
||||
UploadHistoryScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Settings screen
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mattintech.simplelogupload.navigation
|
||||
|
||||
/**
|
||||
* Sealed class representing all navigation destinations in the app
|
||||
*/
|
||||
sealed class Screen(val route: String) {
|
||||
/**
|
||||
* Onboarding carousel with app intro and permissions
|
||||
*/
|
||||
data object Onboarding : Screen("onboarding")
|
||||
|
||||
/**
|
||||
* Welcome/onboarding screen shown on first launch
|
||||
*/
|
||||
data object Welcome : Screen("welcome")
|
||||
|
||||
/**
|
||||
* Server setup screen for configuring server connection
|
||||
*/
|
||||
data object ServerSetup : Screen("server_setup")
|
||||
|
||||
/**
|
||||
* Main screen with upload functionality and bottom navigation
|
||||
*/
|
||||
data object Main : Screen("main")
|
||||
|
||||
/**
|
||||
* Upload history screen showing past uploads
|
||||
*/
|
||||
data object UploadHistory : Screen("upload_history")
|
||||
|
||||
/**
|
||||
* Settings screen for app configuration
|
||||
*/
|
||||
data object Settings : Screen("settings")
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
package com.mattintech.simplelogupload.service
|
||||
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.app.Service.STOP_FOREGROUND_DETACH
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.mattintech.simplelogupload.ComposeMainActivity
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.FileConstants
|
||||
import com.mattintech.simplelogupload.constant.NetworkConstants
|
||||
import com.mattintech.simplelogupload.controller.FileController
|
||||
import com.mattintech.simplelogupload.controller.UploadController
|
||||
import com.mattintech.simplelogupload.db.UploadHistoryRepository
|
||||
import com.mattintech.simplelogupload.model.UploadHistory
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import com.mattintech.simplelogupload.upload.ChunkedUploadManager
|
||||
import com.mattintech.simplelogupload.upload.UploadProgressCallback
|
||||
import com.mattintech.simplelogupload.upload.UploadStrategySelector
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Foreground service for file uploads with progress tracking
|
||||
*/
|
||||
class FileUploadForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = AppConstants.APP_TAG + "UploadService"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
// Intent extras
|
||||
const val EXTRA_FILE_PATH = "extra_file_path"
|
||||
const val EXTRA_MAX_DOWNLOADS = "extra_max_downloads"
|
||||
const val EXTRA_EXPIRY_HOURS = "extra_expiry_hours"
|
||||
}
|
||||
|
||||
private lateinit var uploadController: UploadController
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var repository: UploadHistoryRepository
|
||||
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||
private var lastProgressUpdate: Long = 0
|
||||
|
||||
// Upload state tracking
|
||||
private var currentUploadManager: ChunkedUploadManager? = null
|
||||
private var currentStandardUploadCall: Call? = null
|
||||
private var currentZipJob: kotlinx.coroutines.Job? = null
|
||||
private var uploadState: UploadState = UploadState.IDLE
|
||||
|
||||
enum class UploadState {
|
||||
IDLE, PREPARING, UPLOADING, PAUSED, CANCELLED
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
uploadController = UploadController(this)
|
||||
repository = UploadHistoryRepository(application as Application)
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "========== Foreground Service started ==========")
|
||||
|
||||
if (intent == null) {
|
||||
Log.e(TAG, "Intent is null")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Handle control actions
|
||||
when (intent.action) {
|
||||
AppConstants.ACTION_PAUSE_UPLOAD -> {
|
||||
handlePauseUpload()
|
||||
return START_STICKY
|
||||
}
|
||||
AppConstants.ACTION_RESUME_UPLOAD -> {
|
||||
handleResumeUpload()
|
||||
return START_STICKY
|
||||
}
|
||||
AppConstants.ACTION_CANCEL_UPLOAD -> {
|
||||
handleCancelUpload()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Intent received, extracting parameters...")
|
||||
|
||||
// Get file path from intent
|
||||
var filePath = intent.getStringExtra(EXTRA_FILE_PATH)
|
||||
val maxDownloads = intent.getIntExtra(EXTRA_MAX_DOWNLOADS, NetworkConstants.DEFAULT_MAX_DOWNLOADS)
|
||||
var expiryHours = intent.getIntExtra(EXTRA_EXPIRY_HOURS, NetworkConstants.DEFAULT_EXPIRY_HOURS)
|
||||
|
||||
// Log parameters for debugging
|
||||
Log.d(TAG, "Upload parameters - Max Downloads: $maxDownloads, Expiry Hours: $expiryHours")
|
||||
|
||||
// Validate expiry hours
|
||||
if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) {
|
||||
Log.w(TAG, "Expiry hours exceeded maximum allowed value, capping at ${NetworkConstants.MAX_EXPIRY_HOURS}")
|
||||
expiryHours = NetworkConstants.MAX_EXPIRY_HOURS
|
||||
}
|
||||
|
||||
if (filePath == null) {
|
||||
Log.e(TAG, "No file path provided")
|
||||
// Try to find the most recent dump state file
|
||||
val mostRecentFile = FileController.getMostRecentDumpStateFile()
|
||||
if (mostRecentFile != null) {
|
||||
filePath = mostRecentFile.absolutePath
|
||||
} else {
|
||||
Log.e(TAG, "No file to upload")
|
||||
sendErrorBroadcast("No file to upload")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "File not found: $filePath")
|
||||
sendErrorBroadcast("File not found")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Create a notification and run the service in the foreground
|
||||
Log.d(TAG, "Creating initial notification and starting foreground...")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// For Android 14+ (API 34+)
|
||||
try {
|
||||
Log.d(TAG, "Starting foreground service with DATA_SYNC type (Android 14+)")
|
||||
val dataSync = 1 // ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC value is 1
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification(), dataSync)
|
||||
Log.d(TAG, "Foreground service started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting foreground service with type", e)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
} else {
|
||||
// For Android 13 and below
|
||||
Log.d(TAG, "Starting foreground service (Android <14)")
|
||||
startForeground(NOTIFICATION_ID, createInitialNotification())
|
||||
Log.d(TAG, "Foreground service started successfully")
|
||||
}
|
||||
|
||||
// Mark upload as in progress
|
||||
PreferencesUtil.setUploadInProgress(this, true, file.name)
|
||||
|
||||
// Prepare file for upload (zip if needed based on extension and file type)
|
||||
Log.d(TAG, "Starting file preparation...")
|
||||
|
||||
// Check if file needs zipping to show appropriate message
|
||||
val needsZipping = !com.mattintech.simplelogupload.util.FileUtil.isZipFile(file)
|
||||
|
||||
// Set state to PREPARING
|
||||
uploadState = UploadState.PREPARING
|
||||
|
||||
FileController.prepareFileForUpload(file, object : FileController.FilePrepareCallback {
|
||||
override fun onSuccess(preparedFile: File) {
|
||||
if (uploadState == UploadState.CANCELLED) {
|
||||
Log.d(TAG, "Upload cancelled during preparation")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "File prepared for upload: ${preparedFile.name}")
|
||||
Log.d(TAG, "Starting upload process...")
|
||||
uploadState = UploadState.IDLE
|
||||
uploadFile(preparedFile, maxDownloads, expiryHours)
|
||||
}
|
||||
|
||||
override fun onProgress(progress: Int) {
|
||||
if (uploadState == UploadState.CANCELLED) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle notification updates to every 500ms to ensure visibility
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val shouldUpdate = currentTime - lastProgressUpdate >= 500 || progress == 100
|
||||
|
||||
Log.d(TAG, "File preparation progress: $progress% (shouldUpdate: $shouldUpdate)")
|
||||
|
||||
if (shouldUpdate) {
|
||||
lastProgressUpdate = currentTime
|
||||
val message = if (needsZipping) {
|
||||
"Compressing file: $progress%"
|
||||
} else {
|
||||
"Preparing file: $progress%"
|
||||
}
|
||||
updateNotification(message, progress, 100)
|
||||
}
|
||||
|
||||
// Always send broadcast for in-app UI (not throttled)
|
||||
sendZipProgressBroadcast(progress)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
Log.e(TAG, "Error preparing file", e)
|
||||
PreferencesUtil.setUploadInProgress(this@FileUploadForegroundService, false, null)
|
||||
sendErrorBroadcast("Error preparing file: ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
})
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* Upload a file directly with progress tracking
|
||||
*/
|
||||
private fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
|
||||
// Validate file size before upload
|
||||
if (!FileController.isValidFileSize(file)) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: ${FileConstants.MAX_FILE_SIZE} bytes)")
|
||||
sendErrorBroadcast("File size exceeds the maximum allowed size")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine upload strategy
|
||||
val useChunkedUpload = UploadStrategySelector.shouldUseChunkedUpload(this, file)
|
||||
val networkType = UploadStrategySelector.getNetworkTypeName(this)
|
||||
|
||||
Log.d(TAG, "Starting upload - Network: $networkType, Chunked: $useChunkedUpload, File: ${file.name}")
|
||||
|
||||
// Execute chosen strategy
|
||||
val uploadFuture = if (useChunkedUpload) {
|
||||
performChunkedUpload(file, maxDownloads, expiryHours)
|
||||
} else {
|
||||
performStandardUpload(file, maxDownloads, expiryHours)
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
uploadFuture.thenAccept { result ->
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "Upload successful! Code: ${result.code}")
|
||||
updateNotificationComplete("Upload complete! Code: ${result.code}", result.code)
|
||||
sendSuccessBroadcast(result.code)
|
||||
} else {
|
||||
Log.e(TAG, "Upload failed: ${result.error}")
|
||||
updateNotification("Upload failed: ${result.error}", -1, 100, isComplete = true)
|
||||
sendErrorBroadcast(result.error)
|
||||
}
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Detach notification from foreground service so it persists after service stops
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false) // Keep notification visible
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
}.exceptionally { ex ->
|
||||
Log.e(TAG, "Exception during upload", ex)
|
||||
updateNotification("Upload error: ${ex.message}", -1, 100, isComplete = true)
|
||||
sendErrorBroadcast("Error during upload: ${ex.message}")
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Stop service after a short delay
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false)
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a chunked upload for large files
|
||||
*/
|
||||
private fun performChunkedUpload(
|
||||
file: File,
|
||||
maxDownloads: Int,
|
||||
expiryHours: Int
|
||||
): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
// CRITICAL: Show session initialization progress IMMEDIATELY
|
||||
updateNotification("Initializing upload session...", -1, 100)
|
||||
|
||||
// Create chunked upload manager directly
|
||||
val uploadManager = ChunkedUploadManager(this, file, maxDownloads, expiryHours)
|
||||
|
||||
// Store reference for pause/resume/cancel control
|
||||
currentUploadManager = uploadManager
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
// Set progress listener with direct notification updates
|
||||
uploadManager.setProgressListener(object : ChunkedUploadManager.UploadProgressListener {
|
||||
override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) {
|
||||
// Direct notification update - no intermediate callbacks
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastProgressUpdate >= 500 || percentComplete == 100) {
|
||||
lastProgressUpdate = currentTime
|
||||
|
||||
val uploadedMB = bytesUploaded / (1024f * 1024f)
|
||||
val totalMB = totalBytes / (1024f * 1024f)
|
||||
val statusText = String.format(
|
||||
"Uploading: %.1f / %.1f MB (%d%%)",
|
||||
uploadedMB, totalMB, percentComplete
|
||||
)
|
||||
|
||||
updateNotification(statusText, percentComplete, 100)
|
||||
sendProgressBroadcast(bytesUploaded, totalBytes, percentComplete)
|
||||
Log.d(TAG, "Upload progress: $percentComplete% ($uploadedMB MB / $totalMB MB)")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChunkComplete(chunkIndex: Int, totalChunks: Int) {
|
||||
Log.d(TAG, "Chunk $chunkIndex/$totalChunks complete")
|
||||
}
|
||||
|
||||
override fun onUploadComplete(result: UploadResult) {
|
||||
Log.d(TAG, "Chunked upload complete: $result")
|
||||
|
||||
// Clear upload reference
|
||||
currentUploadManager = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
// Save to history directly if successful
|
||||
if (result.isSuccess) {
|
||||
saveToHistory(result)
|
||||
}
|
||||
|
||||
future.complete(result)
|
||||
}
|
||||
|
||||
override fun onError(error: String) {
|
||||
Log.e(TAG, "Chunked upload error: $error")
|
||||
|
||||
// Clear upload reference
|
||||
currentUploadManager = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
future.complete(UploadResult(error))
|
||||
}
|
||||
|
||||
override fun onPaused() {
|
||||
Log.d(TAG, "Chunked upload paused")
|
||||
// Note: updateNotification and sendPausedBroadcast are called in handlePauseUpload()
|
||||
}
|
||||
|
||||
override fun onResumed() {
|
||||
Log.d(TAG, "Chunked upload resumed")
|
||||
// Note: updateNotification and sendResumedBroadcast are called in handleResumeUpload()
|
||||
}
|
||||
})
|
||||
|
||||
// Start the upload - this triggers session initialization
|
||||
uploadManager.startUpload()
|
||||
.exceptionally { ex ->
|
||||
Log.e(TAG, "Exception during chunked upload", ex)
|
||||
future.complete(UploadResult("Exception during upload: ${ex.message}"))
|
||||
null
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a standard (non-chunked) upload for smaller files
|
||||
*/
|
||||
private fun performStandardUpload(
|
||||
file: File,
|
||||
maxDownloads: Int,
|
||||
expiryHours: Int
|
||||
): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
// Update notification immediately
|
||||
updateNotification("Uploading ${file.name}...", 0, 100)
|
||||
|
||||
// Create OkHttpClient with timeout configuration
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(NetworkConstants.CONNECTION_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.writeTimeout(NetworkConstants.WRITE_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.readTimeout(NetworkConstants.READ_TIMEOUT.toLong(), TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Using standard upload for file: ${file.name}")
|
||||
|
||||
val fileBody = file.asRequestBody("application/octet-stream".toMediaTypeOrNull())
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(NetworkConstants.PARAM_FILE, file.name, fileBody)
|
||||
.addFormDataPart(NetworkConstants.PARAM_MAX_DOWNLOADS, maxDownloads.toString())
|
||||
.addFormDataPart(NetworkConstants.PARAM_EXPIRY_HOURS, expiryHours.toString())
|
||||
.build()
|
||||
|
||||
// Get server URL and client key from preferences
|
||||
val serverUrl = PreferencesUtil.getServerUrl(this)
|
||||
val clientKey = PreferencesUtil.getClientKey(this)
|
||||
|
||||
Log.d(TAG, "Using server URL: $serverUrl")
|
||||
|
||||
// Build request with client-key header
|
||||
val request = Request.Builder()
|
||||
.url(serverUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Sending upload request with ${NetworkConstants.CLIENT_KEY_HEADER} header")
|
||||
|
||||
// Show initial progress
|
||||
updateNotification("Uploading ${file.name}...", 0, 100)
|
||||
|
||||
// Create call and store reference for cancellation
|
||||
val call = client.newCall(request)
|
||||
currentStandardUploadCall = call
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Upload failed", e)
|
||||
|
||||
// Clear upload reference
|
||||
currentStandardUploadCall = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
future.complete(UploadResult("Upload failed: ${e.message}"))
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
// Clear upload reference
|
||||
currentStandardUploadCall = null
|
||||
uploadState = UploadState.IDLE
|
||||
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
Log.d(TAG, "Server response: $responseBody")
|
||||
|
||||
// Update progress to 100% on success
|
||||
updateNotification("Upload complete!", 100, 100)
|
||||
|
||||
// Parse JSON response to get the upload code
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val uploadCode = jsonResponse.getString("code")
|
||||
|
||||
Log.d(TAG, "Upload successful! Code: $uploadCode")
|
||||
|
||||
// Create the result
|
||||
val result = UploadResult(
|
||||
uploadCode,
|
||||
file.name,
|
||||
maxDownloads,
|
||||
expiryHours
|
||||
)
|
||||
|
||||
// Save to history
|
||||
saveToHistory(result)
|
||||
|
||||
future.complete(result)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse server response", e)
|
||||
future.complete(UploadResult("Failed to parse server response"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
val errorMsg = when (response.code) {
|
||||
401, 403 -> {
|
||||
Log.e(TAG, "Authentication error: ${response.code}, Body: $errorBody")
|
||||
"Authentication failed. Please check client key."
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Upload failed: ${response.code}, Body: $errorBody")
|
||||
"Upload failed, Response code: ${response.code}"
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract error message from JSON
|
||||
val finalErrorMsg = try {
|
||||
val errorJson = JSONObject(errorBody)
|
||||
errorJson.optString("error", errorMsg)
|
||||
} catch (e: JSONException) {
|
||||
errorMsg
|
||||
}
|
||||
|
||||
future.complete(UploadResult(finalErrorMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an upload result to the history database
|
||||
*/
|
||||
private fun saveToHistory(result: UploadResult) {
|
||||
if (!result.isSuccess) {
|
||||
return
|
||||
}
|
||||
|
||||
val historyEntry = UploadHistory(
|
||||
fileName = result.fileName,
|
||||
uploadTime = System.currentTimeMillis(),
|
||||
uploadCode = result.code,
|
||||
maxDownloads = result.maxDownloads,
|
||||
expiryHours = result.expiryHours
|
||||
)
|
||||
|
||||
repository.insert(historyEntry)
|
||||
.thenAccept { id -> Log.d(TAG, "Saved to history with ID: $id") }
|
||||
.exceptionally { ex ->
|
||||
Log.e(TAG, "Error saving to history", ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial notification
|
||||
*/
|
||||
private fun createInitialNotification(): Notification {
|
||||
val notificationIntent = Intent(this, ComposeMainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, AppConstants.NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("File Upload")
|
||||
.setContentText("Preparing file...")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setProgress(100, 0, true) // Start with indeterminate progress
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true) // Don't alert on every progress update
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH) // High priority for better visibility
|
||||
.addAction(createCancelAction()) // Add cancel action from the start
|
||||
.apply {
|
||||
// Force immediate notification display on Android 14+ (prevents system delay)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
}
|
||||
}
|
||||
|
||||
notificationBuilder = builder
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification with progress
|
||||
*
|
||||
* @param status The status message
|
||||
* @param progress Current progress value (-1 for indeterminate)
|
||||
* @param max Maximum progress value
|
||||
* @param isComplete Whether the upload is complete (makes notification dismissible)
|
||||
*/
|
||||
private fun updateNotification(status: String, progress: Int = -1, max: Int = 100, isComplete: Boolean = false) {
|
||||
val builder = notificationBuilder ?: return
|
||||
|
||||
Log.d(TAG, "Updating notification: $status (progress: $progress/$max)")
|
||||
|
||||
// Ignore updates if upload is cancelled (unless this IS the cancellation message)
|
||||
if (uploadState == UploadState.CANCELLED && !isComplete) {
|
||||
Log.d(TAG, "Ignoring notification update because upload is cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
builder.setContentText(status)
|
||||
|
||||
if (progress >= 0 && !isComplete) {
|
||||
// Determinate progress
|
||||
builder.setProgress(max, progress, false)
|
||||
} else if (!isComplete) {
|
||||
// Indeterminate progress
|
||||
builder.setProgress(max, 0, true)
|
||||
}
|
||||
|
||||
// Add action buttons based on upload state
|
||||
if (!isComplete && uploadState != UploadState.CANCELLED) {
|
||||
builder.clearActions() // Remove old actions
|
||||
|
||||
when (uploadState) {
|
||||
UploadState.PREPARING -> {
|
||||
// Show Cancel button during file preparation/zipping
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
UploadState.PAUSED -> {
|
||||
// Show Resume and Cancel buttons
|
||||
builder.addAction(createResumeAction())
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
UploadState.UPLOADING -> {
|
||||
// Show Pause (if chunked) and Cancel buttons
|
||||
if (currentUploadManager != null) {
|
||||
builder.addAction(createPauseAction())
|
||||
}
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
else -> {
|
||||
// For IDLE or other states, just show Cancel
|
||||
builder.addAction(createCancelAction())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When complete or cancelled, make notification dismissible and remove progress bar
|
||||
if (isComplete || uploadState == UploadState.CANCELLED) {
|
||||
builder.clearActions() // Remove all actions
|
||||
builder.setOngoing(false)
|
||||
.setProgress(0, 0, false) // Remove progress bar
|
||||
.setAutoCancel(true) // Allow swipe to dismiss
|
||||
}
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification with upload complete status and code
|
||||
*
|
||||
* @param status The status message
|
||||
* @param uploadCode The upload code to display
|
||||
*/
|
||||
private fun updateNotificationComplete(status: String, uploadCode: String) {
|
||||
val builder = notificationBuilder ?: return
|
||||
|
||||
Log.d(TAG, "Updating notification with upload code: $uploadCode")
|
||||
|
||||
builder.setContentTitle("Upload Successful!")
|
||||
.setContentText(status)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(status))
|
||||
.setProgress(0, 0, false) // Remove progress bar
|
||||
.setOngoing(false) // Not ongoing, can be dismissed
|
||||
.setAutoCancel(true) // Swipe to dismiss
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the notification channel (required for Android 8.0+)
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name: CharSequence = "File Upload Channel"
|
||||
val description = "Notification channel for file upload progress"
|
||||
val importance = NotificationManager.IMPORTANCE_HIGH // Changed to HIGH for better visibility
|
||||
val channel = NotificationChannel(
|
||||
AppConstants.NOTIFICATION_CHANNEL_ID,
|
||||
name,
|
||||
importance
|
||||
).apply {
|
||||
this.description = description
|
||||
setShowBadge(true)
|
||||
enableVibration(false) // Don't vibrate on progress updates
|
||||
enableLights(false) // Don't flash lights on progress updates
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with the upload success result
|
||||
*/
|
||||
private fun sendSuccessBroadcast(uploadCode: String) {
|
||||
Log.d(TAG, "Sending success broadcast with code: $uploadCode")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_COMPLETE).apply {
|
||||
putExtra(AppConstants.EXTRA_UPLOAD_CODE, uploadCode)
|
||||
}
|
||||
|
||||
// Send both a regular broadcast and a local broadcast to ensure delivery
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with the upload error result
|
||||
*/
|
||||
private fun sendErrorBroadcast(errorMessage: String) {
|
||||
Log.e(TAG, "Sending error broadcast: $errorMessage")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_ERROR).apply {
|
||||
putExtra(AppConstants.EXTRA_UPLOAD_ERROR, errorMessage)
|
||||
}
|
||||
|
||||
// Send both a regular broadcast and a local broadcast to ensure delivery
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with upload progress update
|
||||
*/
|
||||
private fun sendProgressBroadcast(bytesUploaded: Long, totalBytes: Long, percentComplete: Int) {
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_PROGRESS).apply {
|
||||
putExtra(AppConstants.EXTRA_BYTES_UPLOADED, bytesUploaded)
|
||||
putExtra(AppConstants.EXTRA_TOTAL_BYTES, totalBytes)
|
||||
putExtra(AppConstants.EXTRA_PERCENT_COMPLETE, percentComplete)
|
||||
}
|
||||
|
||||
// Send local broadcast to the ViewModel
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast with zip/compression progress update
|
||||
*/
|
||||
private fun sendZipProgressBroadcast(percentComplete: Int) {
|
||||
val intent = Intent(AppConstants.ACTION_ZIP_PROGRESS).apply {
|
||||
putExtra(AppConstants.EXTRA_PERCENT_COMPLETE, percentComplete)
|
||||
}
|
||||
|
||||
// Send local broadcast to the ViewModel
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is paused
|
||||
*/
|
||||
private fun sendPausedBroadcast() {
|
||||
Log.d(TAG, "Sending paused broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_PAUSED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is resumed
|
||||
*/
|
||||
private fun sendResumedBroadcast() {
|
||||
Log.d(TAG, "Sending resumed broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_RESUMED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast when upload is cancelled
|
||||
*/
|
||||
private fun sendCancelledBroadcast() {
|
||||
Log.d(TAG, "Sending cancelled broadcast")
|
||||
val intent = Intent(AppConstants.ACTION_UPLOAD_CANCELLED)
|
||||
sendBroadcast(intent)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pause action for the notification
|
||||
*/
|
||||
private fun createPauseAction(): NotificationCompat.Action {
|
||||
val pauseIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_PAUSE_UPLOAD
|
||||
}
|
||||
val pausePendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
pauseIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
pausePendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resume action for the notification
|
||||
*/
|
||||
private fun createResumeAction(): NotificationCompat.Action {
|
||||
val resumeIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_RESUME_UPLOAD
|
||||
}
|
||||
val resumePendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
resumeIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Resume",
|
||||
resumePendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancel action for the notification
|
||||
*/
|
||||
private fun createCancelAction(): NotificationCompat.Action {
|
||||
val cancelIntent = Intent(this, FileUploadForegroundService::class.java).apply {
|
||||
action = AppConstants.ACTION_CANCEL_UPLOAD
|
||||
}
|
||||
val cancelPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
3,
|
||||
cancelIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"Cancel",
|
||||
cancelPendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pause upload request
|
||||
*/
|
||||
private fun handlePauseUpload() {
|
||||
Log.d(TAG, "Handling pause upload request")
|
||||
|
||||
if (uploadState != UploadState.UPLOADING) {
|
||||
Log.w(TAG, "Cannot pause: upload not in progress (state: $uploadState)")
|
||||
return
|
||||
}
|
||||
|
||||
uploadState = UploadState.PAUSED
|
||||
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.pauseUpload()
|
||||
updateNotification("Upload paused", -1, 100)
|
||||
sendPausedBroadcast()
|
||||
Log.d(TAG, "Chunked upload paused successfully")
|
||||
} ?: run {
|
||||
Log.w(TAG, "Cannot pause standard upload (not supported)")
|
||||
updateNotification("Cannot pause standard upload", -1, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resume upload request
|
||||
*/
|
||||
private fun handleResumeUpload() {
|
||||
Log.d(TAG, "Handling resume upload request")
|
||||
|
||||
if (uploadState != UploadState.PAUSED) {
|
||||
Log.w(TAG, "Cannot resume: upload not paused (state: $uploadState)")
|
||||
return
|
||||
}
|
||||
|
||||
uploadState = UploadState.UPLOADING
|
||||
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.resumeUpload()
|
||||
updateNotification("Resuming upload...", -1, 100)
|
||||
sendResumedBroadcast()
|
||||
Log.d(TAG, "Chunked upload resumed successfully")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel upload request
|
||||
*/
|
||||
private fun handleCancelUpload() {
|
||||
Log.d(TAG, "Handling cancel upload request")
|
||||
|
||||
uploadState = UploadState.CANCELLED
|
||||
|
||||
// Cancel file preparation (zipping) if in progress
|
||||
FileController.cancelPreparation()
|
||||
|
||||
// Cancel chunked upload
|
||||
currentUploadManager?.let { manager ->
|
||||
manager.cancelUpload()
|
||||
currentUploadManager = null
|
||||
Log.d(TAG, "Cancelled chunked upload")
|
||||
}
|
||||
|
||||
// Cancel standard upload
|
||||
currentStandardUploadCall?.let { call ->
|
||||
call.cancel()
|
||||
currentStandardUploadCall = null
|
||||
Log.d(TAG, "Cancelled standard upload")
|
||||
}
|
||||
|
||||
// Clear upload in progress flag
|
||||
PreferencesUtil.setUploadInProgress(this, false, null)
|
||||
|
||||
// Send cancelled broadcast
|
||||
sendCancelledBroadcast()
|
||||
|
||||
// Update notification
|
||||
updateNotification("Upload cancelled", -1, 100, isComplete = true)
|
||||
|
||||
// Stop service after a short delay
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(false)
|
||||
}
|
||||
stopSelf()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.mattintech.simplelogupload.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
|
||||
/**
|
||||
* Dialog for entering upload parameters
|
||||
*
|
||||
* @param onDismiss Callback when dialog is dismissed
|
||||
* @param onConfirm Callback when upload is confirmed with (maxDownloads, expiryHours)
|
||||
*/
|
||||
@Composable
|
||||
fun UploadParamsDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int, Int) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var maxDownloads by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultMaxDownloads(context).toString())
|
||||
}
|
||||
var expiryHours by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultExpiryHours(context).toString())
|
||||
}
|
||||
|
||||
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
|
||||
var expiryHoursError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validateAndConfirm() {
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
val maxDl = maxDownloads.toIntOrNull()
|
||||
if (maxDl == null || maxDl < 0) {
|
||||
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
val expiry = expiryHours.toIntOrNull()
|
||||
if (expiry == null || expiry <= 0 || expiry > 24) {
|
||||
expiryHoursError = "Must be between 1 and 24 hours"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onConfirm(maxDl!!, expiry!!)
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Upload Parameters") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Instructions
|
||||
Text(
|
||||
text = "Edit the values below to customize upload settings:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Max Downloads
|
||||
OutlinedTextField(
|
||||
value = maxDownloads,
|
||||
onValueChange = {
|
||||
maxDownloads = it
|
||||
maxDownloadsError = null
|
||||
},
|
||||
label = { Text("Maximum Downloads (tap to edit)") },
|
||||
placeholder = { Text("Enter number") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit max downloads",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
|
||||
},
|
||||
isError = maxDownloadsError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
// Expiry Hours
|
||||
OutlinedTextField(
|
||||
value = expiryHours,
|
||||
onValueChange = {
|
||||
expiryHours = it
|
||||
expiryHoursError = null
|
||||
},
|
||||
label = { Text("Expiry Time in hours (tap to edit)") },
|
||||
placeholder = { Text("Enter hours") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit expiry hours",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
|
||||
},
|
||||
isError = expiryHoursError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { validateAndConfirm() }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { validateAndConfirm() }) {
|
||||
Text("Upload")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.mattintech.simplelogupload.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Dialog for displaying upload success result with upload code
|
||||
*
|
||||
* @param uploadCode The upload code to display
|
||||
* @param onDismiss Callback when dialog is dismissed
|
||||
*/
|
||||
@Composable
|
||||
fun UploadResultDialog(
|
||||
uploadCode: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
fun copyCodeToClipboard() {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Upload Code", uploadCode)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Code copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Upload Successful!",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Your upload code is:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
// Upload code in a card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = uploadCode,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
IconButton(onClick = { copyCodeToClipboard() }) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Share this code to allow others to download your file.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mattintech.simplelogupload.ui.screens.tabs.DumpstateTab
|
||||
import com.mattintech.simplelogupload.ui.screens.tabs.GeneralUploadTab
|
||||
import com.mattintech.simplelogupload.util.DeviceUtil
|
||||
import com.mattintech.simplelogupload.viewmodel.MainViewModel
|
||||
|
||||
/**
|
||||
* Main screen with upload functionality and bottom navigation
|
||||
* Shows different tabs based on device type (Samsung vs others)
|
||||
*
|
||||
* @param onNavigateToHistory Callback to navigate to upload history
|
||||
* @param onNavigateToSettings Callback to navigate to settings
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onNavigateToHistory: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
viewModel: MainViewModel = viewModel()
|
||||
) {
|
||||
val isSamsungDevice = remember { DeviceUtil.isSamsungDevice() }
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SimpleLogUpload") },
|
||||
actions = {
|
||||
// History button
|
||||
IconButton(onClick = onNavigateToHistory) {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = "Upload History"
|
||||
)
|
||||
}
|
||||
|
||||
// Settings button
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
contentDescription = "Settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Only show bottom navigation on Samsung devices
|
||||
if (isSamsungDevice) {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTabIndex == 0,
|
||||
onClick = { selectedTabIndex = 0 },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Upload,
|
||||
contentDescription = "General Upload"
|
||||
)
|
||||
},
|
||||
label = { Text("Upload") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTabIndex == 1,
|
||||
onClick = { selectedTabIndex = 1 },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.BugReport,
|
||||
contentDescription = "Dumpstate"
|
||||
)
|
||||
},
|
||||
label = { Text("Dumpstate") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Global upload progress indicator - visible across all tabs
|
||||
if (uiState.isLoading) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (uiState.isPaused) "Upload Paused" else "Uploading",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
if (uiState.totalBytes > 0) {
|
||||
Text(
|
||||
text = "${uiState.uploadProgress}%",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Progress bar
|
||||
LinearProgressIndicator(
|
||||
progress = { uiState.uploadProgress / 100f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
)
|
||||
|
||||
// Progress details
|
||||
if (uiState.totalBytes > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val uploadedMB = uiState.bytesUploaded / (1024f * 1024f)
|
||||
val totalMB = uiState.totalBytes / (1024f * 1024f)
|
||||
Text(
|
||||
text = String.format("%.1f / %.1f MB", uploadedMB, totalMB),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
// Status message
|
||||
uiState.uploadStatusMessage?.let { message ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (uiState.isPaused) {
|
||||
// Show Resume button
|
||||
TextButton(
|
||||
onClick = { viewModel.resumeUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Resume",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Resume")
|
||||
}
|
||||
} else {
|
||||
// Show Pause button (only for chunked uploads)
|
||||
if (uiState.isChunkedUpload) {
|
||||
TextButton(
|
||||
onClick = { viewModel.pauseUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Pause,
|
||||
contentDescription = "Pause",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Cancel button (always shown)
|
||||
TextButton(
|
||||
onClick = { viewModel.cancelUpload() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Cancel",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
when {
|
||||
// Non-Samsung device: show only general upload
|
||||
!isSamsungDevice -> {
|
||||
GeneralUploadTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// Samsung device with tab 0 selected: general upload
|
||||
selectedTabIndex == 0 -> {
|
||||
GeneralUploadTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// Samsung device with tab 1 selected: dumpstate
|
||||
else -> {
|
||||
DumpstateTab(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Onboarding screen with carousel explaining app and permissions
|
||||
*
|
||||
* @param onOnboardingComplete Callback when onboarding is complete
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onOnboardingComplete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as Activity
|
||||
val pagerState = rememberPagerState(pageCount = { 5 })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Permission states
|
||||
var notificationPermissionGranted by remember {
|
||||
mutableStateOf(PermissionUtil.hasNotificationPermission(context))
|
||||
}
|
||||
var filesPermissionGranted by remember {
|
||||
mutableStateOf(PermissionUtil.hasAllFilesAccessPermission())
|
||||
}
|
||||
|
||||
// Server configuration states
|
||||
var serverAddress by remember { mutableStateOf("") }
|
||||
var serverPort by remember { mutableStateOf("") }
|
||||
var clientKey by remember { mutableStateOf("") }
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Notification permission launcher
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
notificationPermissionGranted = isGranted
|
||||
if (isGranted) {
|
||||
// Auto-advance to next page after permission granted
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Notification permission helps you track upload progress",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
// Still allow advancing even if denied
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files access launcher
|
||||
val filesPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
filesPermissionGranted = PermissionUtil.hasAllFilesAccessPermission()
|
||||
if (filesPermissionGranted) {
|
||||
// Auto-advance to server setup page
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"File access is needed to upload files from your device",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
// Still allow advancing even if denied
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher (for QR scanning)
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Pager
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f),
|
||||
userScrollEnabled = false // Disable manual scrolling
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> WelcomePage(
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
1 -> NotificationPermissionPage(
|
||||
isGranted = notificationPermissionGranted,
|
||||
onRequestPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
// On older versions, permission is granted by default
|
||||
notificationPermissionGranted = true
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
2 -> FilesPermissionPage(
|
||||
isGranted = filesPermissionGranted,
|
||||
onRequestPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
filesPermissionLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
filesPermissionLauncher.launch(intent)
|
||||
}
|
||||
} else {
|
||||
// On older versions, permission is granted by default
|
||||
filesPermissionGranted = true
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
3 -> ServerSetupPage(
|
||||
serverAddress = serverAddress,
|
||||
serverPort = serverPort,
|
||||
clientKey = clientKey,
|
||||
serverAddressError = serverAddressError,
|
||||
serverPortError = serverPortError,
|
||||
clientKeyError = clientKeyError,
|
||||
onServerAddressChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
onServerPortChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
onClientKeyChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
onScanQr = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
onContinue = {
|
||||
// Validate server settings
|
||||
var hasError = false
|
||||
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setSetupCompleted(context, true)
|
||||
|
||||
// Advance to final page
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
4 -> ReadyPage(
|
||||
onGetStarted = {
|
||||
// Mark onboarding as complete
|
||||
PreferencesUtil.setOnboardingCompleted(context, true)
|
||||
onOnboardingComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Page indicator
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
repeat(5) { iteration ->
|
||||
val color = if (pagerState.currentPage == iteration) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(8.dp)
|
||||
.background(color, shape = MaterialTheme.shapes.small)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome page explaining the app
|
||||
*/
|
||||
@Composable
|
||||
private fun WelcomePage(
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
title = "Welcome to SimpleLogUpload",
|
||||
description = "A simple and secure way to upload files to your server.\n\n" +
|
||||
"This app requires a backend server to function. Your administrator should have " +
|
||||
"provided you with server details.\n\n" +
|
||||
"Let's get your app set up!",
|
||||
buttonText = "Continue",
|
||||
onButtonClick = onContinue
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification permission page
|
||||
*/
|
||||
@Composable
|
||||
private fun NotificationPermissionPage(
|
||||
isGranted: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Notifications,
|
||||
title = "Upload Progress Notifications",
|
||||
description = "SimpleLogUpload needs notification permission to:\n\n" +
|
||||
"• Show upload progress in real-time\n" +
|
||||
"• Display upload completion status\n" +
|
||||
"• Keep you informed about file transfers\n\n" +
|
||||
(if (isGranted) "✓ Permission granted!" else "This helps you track uploads even when the app is in the background."),
|
||||
buttonText = if (isGranted) "Continue" else "Grant Permission",
|
||||
onButtonClick = onRequestPermission,
|
||||
isPermissionGranted = isGranted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files permission page
|
||||
*/
|
||||
@Composable
|
||||
private fun FilesPermissionPage(
|
||||
isGranted: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Folder,
|
||||
title = "File Access Permission",
|
||||
description = "SimpleLogUpload needs access to your files to:\n\n" +
|
||||
"• Browse and select files to upload\n" +
|
||||
"• Access dumpstate files (on Samsung devices)\n" +
|
||||
"• Read files from any location on your device\n\n" +
|
||||
(if (isGranted) "✓ Permission granted!" else "You'll be taken to Settings to grant this permission."),
|
||||
buttonText = if (isGranted) "Continue" else "Grant Permission",
|
||||
onButtonClick = onRequestPermission,
|
||||
isPermissionGranted = isGranted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server setup page
|
||||
*/
|
||||
@Composable
|
||||
private fun ServerSetupPage(
|
||||
serverAddress: String,
|
||||
serverPort: String,
|
||||
clientKey: String,
|
||||
serverAddressError: String?,
|
||||
serverPortError: String?,
|
||||
clientKeyError: String?,
|
||||
onServerAddressChange: (String) -> Unit,
|
||||
onServerPortChange: (String) -> Unit,
|
||||
onClientKeyChange: (String) -> Unit,
|
||||
onScanQr: () -> Unit,
|
||||
onContinue: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Storage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter your server details or scan a QR code from your admin panel.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = onServerAddressChange,
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = onServerPortChange,
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = onClientKeyChange,
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = onScanQr,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Continue",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready page - final page
|
||||
*/
|
||||
@Composable
|
||||
private fun ReadyPage(
|
||||
onGetStarted: () -> Unit
|
||||
) {
|
||||
OnboardingPageTemplate(
|
||||
icon = Icons.Default.Check,
|
||||
title = "You're All Set!",
|
||||
description = "Your app is now configured and ready to use!\n\n" +
|
||||
"✓ Permissions granted\n" +
|
||||
"✓ Server configured\n\n" +
|
||||
"You can now upload files to your server. " +
|
||||
"Tap the button below to start using SimpleLogUpload!",
|
||||
buttonText = "Start Using App",
|
||||
onButtonClick = onGetStarted
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable template for onboarding pages
|
||||
*/
|
||||
@Composable
|
||||
private fun OnboardingPageTemplate(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
buttonText: String,
|
||||
onButtonClick: () -> Unit,
|
||||
isPermissionGranted: Boolean = false
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onButtonClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = buttonText,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Server setup screen for configuring server connection
|
||||
*
|
||||
* @param onSetupComplete Callback when setup is successfully completed
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerSetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var serverAddress by remember { mutableStateOf("") }
|
||||
var serverPort by remember { mutableStateOf("") }
|
||||
var clientKey by remember { mutableStateOf("") }
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setSetupCompleted(context, true)
|
||||
|
||||
Toast.makeText(context, "Setup completed successfully!", Toast.LENGTH_SHORT).show()
|
||||
onSetupComplete()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Server Setup") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Instructions card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your server connection details below, or scan a QR code from your server admin panel to auto-fill.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Finish Setup Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Finish Setup",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Settings screen for modifying server configuration
|
||||
*
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Load existing settings
|
||||
var serverAddress by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerAddress(context))
|
||||
}
|
||||
var serverPort by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerPort(context).toString())
|
||||
}
|
||||
var clientKey by remember {
|
||||
mutableStateOf(PreferencesUtil.getClientKey(context))
|
||||
}
|
||||
var defaultMaxDownloads by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultMaxDownloads(context).toString())
|
||||
}
|
||||
var defaultExpiryHours by remember {
|
||||
mutableStateOf(PreferencesUtil.getDefaultExpiryHours(context).toString())
|
||||
}
|
||||
var askBeforeUpload by remember {
|
||||
mutableStateOf(PreferencesUtil.getAskBeforeUpload(context))
|
||||
}
|
||||
var enableParallelUploads by remember {
|
||||
mutableStateOf(PreferencesUtil.isParallelUploadsEnabled(context))
|
||||
}
|
||||
var clientKeyVisible by remember { mutableStateOf(false) }
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
|
||||
var expiryHoursError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate max downloads
|
||||
val maxDl = defaultMaxDownloads.toIntOrNull()
|
||||
if (maxDl == null || maxDl < 0) {
|
||||
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate expiry hours
|
||||
val expiry = defaultExpiryHours.toIntOrNull()
|
||||
if (expiry == null || expiry <= 0 || expiry > 24) {
|
||||
expiryHoursError = "Must be between 1 and 24 hours"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setDefaultMaxDownloads(context, maxDl!!)
|
||||
PreferencesUtil.setDefaultExpiryHours(context, expiry!!)
|
||||
PreferencesUtil.setAskBeforeUpload(context, askBeforeUpload)
|
||||
PreferencesUtil.setParallelUploadsEnabled(context, enableParallelUploads)
|
||||
|
||||
Toast.makeText(context, "Settings saved successfully!", Toast.LENGTH_SHORT).show()
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
fun resetSettings() {
|
||||
// Reload original values
|
||||
serverAddress = PreferencesUtil.getServerAddress(context)
|
||||
serverPort = PreferencesUtil.getServerPort(context).toString()
|
||||
clientKey = PreferencesUtil.getClientKey(context)
|
||||
defaultMaxDownloads = PreferencesUtil.getDefaultMaxDownloads(context).toString()
|
||||
defaultExpiryHours = PreferencesUtil.getDefaultExpiryHours(context).toString()
|
||||
askBeforeUpload = PreferencesUtil.getAskBeforeUpload(context)
|
||||
enableParallelUploads = PreferencesUtil.isParallelUploadsEnabled(context)
|
||||
|
||||
// Clear errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
Toast.makeText(context, "Settings reset", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
visualTransformation = if (clientKeyVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { clientKeyVisible = !clientKeyVisible }) {
|
||||
Icon(
|
||||
imageVector = if (clientKeyVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (clientKeyVisible) "Hide client key" else "Show client key"
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code to Auto-Fill")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Upload Defaults Section
|
||||
Text(
|
||||
text = "Upload Defaults",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Default Max Downloads
|
||||
OutlinedTextField(
|
||||
value = defaultMaxDownloads,
|
||||
onValueChange = {
|
||||
defaultMaxDownloads = it
|
||||
maxDownloadsError = null
|
||||
},
|
||||
label = { Text("Default Maximum Downloads") },
|
||||
placeholder = { Text("5") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = maxDownloadsError != null,
|
||||
supportingText = {
|
||||
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Default Expiry Hours
|
||||
OutlinedTextField(
|
||||
value = defaultExpiryHours,
|
||||
onValueChange = {
|
||||
defaultExpiryHours = it
|
||||
expiryHoursError = null
|
||||
},
|
||||
label = { Text("Default Expiry Time (hours)") },
|
||||
placeholder = { Text("24") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = expiryHoursError != null,
|
||||
supportingText = {
|
||||
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Ask Before Upload Switch
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Ask Before Upload",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (askBeforeUpload) "Show upload parameters dialog" else "Use default settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = askBeforeUpload,
|
||||
onCheckedChange = { askBeforeUpload = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Parallel Uploads Switch
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Upload Mode",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (enableParallelUploads)
|
||||
"Parallel: 2 chunks simultaneously (Experimental)"
|
||||
else
|
||||
"Sequential: One chunk at a time (Recommended)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enableParallelUploads,
|
||||
onCheckedChange = { enableParallelUploads = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Reset Button
|
||||
OutlinedButton(
|
||||
onClick = { resetSettings() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mattintech.simplelogupload.model.UploadHistory
|
||||
import com.mattintech.simplelogupload.viewmodel.UploadHistoryViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Upload history screen showing past uploads
|
||||
*
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UploadHistoryScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: UploadHistoryViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val history by viewModel.allHistory.observeAsState(emptyList())
|
||||
val deleteStatus by viewModel.deleteStatus.observeAsState()
|
||||
|
||||
// Show toast on delete status
|
||||
LaunchedEffect(deleteStatus) {
|
||||
deleteStatus?.let { success ->
|
||||
val message = if (success) {
|
||||
"Upload deleted successfully"
|
||||
} else {
|
||||
"Failed to delete upload"
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Upload History") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (history.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "No upload history",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Your uploaded files will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// History list
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(history, key = { it.id.toString() }) { item ->
|
||||
UploadHistoryItem(
|
||||
history = item,
|
||||
onCopyCode = { code ->
|
||||
copyToClipboard(context, code)
|
||||
Toast.makeText(context, "Code copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
onDelete = { uploadHistory ->
|
||||
viewModel.delete(uploadHistory)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual upload history item
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadHistoryItem(
|
||||
history: UploadHistory,
|
||||
onCopyCode: (String) -> Unit,
|
||||
onDelete: (UploadHistory) -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Filename
|
||||
Text(
|
||||
text = history.fileName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Upload code
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Code: ${history.uploadCode}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = formatDate(history.uploadTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
// Copy button
|
||||
IconButton(onClick = { onCopyCode(history.uploadCode) }) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// Delete button
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Upload") },
|
||||
text = { Text("Are you sure you want to delete this upload? This will also remove the file from the server.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete(history)
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Upload Code", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.R
|
||||
|
||||
/**
|
||||
* Welcome screen shown on first launch
|
||||
* Guides users to server setup
|
||||
*
|
||||
* @param onNavigateToSetup Callback when user clicks continue button
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onNavigateToSetup: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Top section with logo and title
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// App logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_icon),
|
||||
contentDescription = "SimpleLogUpload logo",
|
||||
modifier = Modifier.size(120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Welcome title
|
||||
Text(
|
||||
text = "Welcome to SimpleLogUpload",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Description
|
||||
Text(
|
||||
text = "Before you can use this app, you need to configure it to connect to your SimpleLogUpload server instance.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Requirements card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "The app requires:",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
RequirementItem(
|
||||
title = "Server Address",
|
||||
description = "IP or hostname of your server"
|
||||
)
|
||||
|
||||
RequirementItem(
|
||||
title = "Server Port",
|
||||
description = "Port your server is running on"
|
||||
)
|
||||
|
||||
RequirementItem(
|
||||
title = "Client Key",
|
||||
description = "Authentication key for secure uploads"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Your administrator should have provided these details.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom button
|
||||
Button(
|
||||
onClick = onNavigateToSetup,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Continue to Setup",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual requirement item with bullet point
|
||||
*/
|
||||
@Composable
|
||||
private fun RequirementItem(
|
||||
title: String,
|
||||
description: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
Text(
|
||||
text = "• ",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.mattintech.simplelogupload.ui.screens.tabs
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
|
||||
import com.mattintech.simplelogupload.ui.components.UploadResultDialog
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.viewmodel.MainViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Dumpstate upload tab for Samsung devices
|
||||
* Shows detected dumpstate files with multi-select
|
||||
*
|
||||
* @param modifier Modifier for the composable
|
||||
* @param viewModel MainViewModel instance
|
||||
*/
|
||||
@Composable
|
||||
fun DumpstateTab(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// State to track pending upload parameters
|
||||
var pendingUpload by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
|
||||
// Notification permission launcher
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Permission granted, proceed with pending upload
|
||||
pendingUpload?.let { (maxDownloads, expiryHours) ->
|
||||
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
|
||||
}
|
||||
pendingUpload = null
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Notification permission is required to show upload progress",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check permission and upload
|
||||
val uploadWithPermissionCheck: (Int, Int) -> Unit = { maxDownloads, expiryHours ->
|
||||
if (PermissionUtil.hasNotificationPermission(context)) {
|
||||
// Permission already granted, proceed with upload
|
||||
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
|
||||
} else {
|
||||
// Need to request permission first
|
||||
pendingUpload = Pair(maxDownloads, expiryHours)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
LaunchedEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Header card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Samsung Dumpstate Files",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = when {
|
||||
uiState.dumpstateFiles.isEmpty() -> "No dumpstate files found on this device"
|
||||
uiState.dumpstateFiles.size == 1 -> "1 dumpstate file found"
|
||||
else -> "${uiState.dumpstateFiles.size} dumpstate files found"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when {
|
||||
uiState.dumpstateFiles.isEmpty() -> {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "No dumpstate files detected",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Generate a dumpstate file from your device settings",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { viewModel.loadDumpstateFiles() }) {
|
||||
Text("Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Selection controls
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = { viewModel.selectAllDumpstates() }) {
|
||||
Text("Select All")
|
||||
}
|
||||
TextButton(onClick = { viewModel.clearDumpstateSelections() }) {
|
||||
Text("Clear All")
|
||||
}
|
||||
}
|
||||
|
||||
// File list
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(uiState.dumpstateFiles) { index, file ->
|
||||
DumpstateFileItem(
|
||||
file = file,
|
||||
isSelected = uiState.selectedDumpstateIndices.contains(index),
|
||||
onToggle = { viewModel.toggleDumpstateSelection(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Upload button
|
||||
Button(
|
||||
onClick = {
|
||||
if (PreferencesUtil.getAskBeforeUpload(context)) {
|
||||
viewModel.showUploadParamsDialog()
|
||||
} else {
|
||||
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
|
||||
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading && uiState.selectedDumpstateIndices.isNotEmpty()
|
||||
) {
|
||||
val count = uiState.selectedDumpstateIndices.size
|
||||
Text(
|
||||
text = if (count > 0) "Upload Selected Files ($count)" else "Select Files to Upload",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload params dialog
|
||||
if (uiState.showUploadParamsDialog) {
|
||||
UploadParamsDialog(
|
||||
onDismiss = { viewModel.hideUploadParamsDialog() },
|
||||
onConfirm = { maxDownloads, expiryHours ->
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload result dialog
|
||||
if (uiState.showUploadResultDialog) {
|
||||
uiState.uploadResult?.let { result ->
|
||||
UploadResultDialog(
|
||||
uploadCode = result.code,
|
||||
onDismiss = { viewModel.hideUploadResultDialog() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual dumpstate file item with checkbox
|
||||
*/
|
||||
@Composable
|
||||
private fun DumpstateFileItem(
|
||||
file: java.io.File,
|
||||
isSelected: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onToggle() }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = file.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Size: ${formatFileSize(file.length())} • ${formatDate(file.lastModified())}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human-readable string
|
||||
*/
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
|
||||
else -> "${bytes / (1024 * 1024 * 1024)} GB"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to readable date
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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.Close
|
||||
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)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Selected File",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.clearSelectedFile() },
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove selected file",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (PreferencesUtil.getAskBeforeUpload(context)) {
|
||||
viewModel.showUploadParamsDialog()
|
||||
} else {
|
||||
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
|
||||
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading
|
||||
) {
|
||||
Text("Upload File")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Upload params dialog
|
||||
if (uiState.showUploadParamsDialog) {
|
||||
UploadParamsDialog(
|
||||
onDismiss = { viewModel.hideUploadParamsDialog() },
|
||||
onConfirm = { maxDownloads, expiryHours ->
|
||||
uploadWithPermissionCheck(maxDownloads, expiryHours)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Upload result dialog
|
||||
if (uiState.showUploadResultDialog) {
|
||||
uiState.uploadResult?.let { result ->
|
||||
UploadResultDialog(
|
||||
uploadCode = result.code,
|
||||
onDismiss = { viewModel.hideUploadResultDialog() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Google Blue Primary (matching app icon)
|
||||
val GoogleBlue = Color(0xFF4285F4)
|
||||
val GoogleBlueDark = Color(0xFF1A73E8)
|
||||
val GoogleBlueLight = Color(0xFF669DF6)
|
||||
|
||||
// Google Green Accent (matching app icon)
|
||||
val GoogleGreen = Color(0xFF34A853)
|
||||
val GoogleGreenDark = Color(0xFF0F9D58)
|
||||
val GoogleGreenLight = Color(0xFF57BB8A)
|
||||
|
||||
// Neutral colors
|
||||
val White = Color(0xFFFFFFFF)
|
||||
val Black = Color(0xFF000000)
|
||||
val Gray50 = Color(0xFFF8F9FA)
|
||||
val Gray100 = Color(0xFFF1F3F4)
|
||||
val Gray200 = Color(0xFFE8EAED)
|
||||
val Gray700 = Color(0xFF5F6368)
|
||||
val Gray900 = Color(0xFF202124)
|
||||
|
||||
// System colors
|
||||
val ErrorRed = Color(0xFFB00020)
|
||||
val ErrorRedLight = Color(0xFFCF6679)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Light color scheme using Google Blue and Green from app icon
|
||||
*/
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = GoogleBlue,
|
||||
onPrimary = White,
|
||||
primaryContainer = GoogleBlueLight,
|
||||
onPrimaryContainer = Gray900,
|
||||
secondary = GoogleGreen,
|
||||
onSecondary = White,
|
||||
secondaryContainer = GoogleGreenLight,
|
||||
onSecondaryContainer = Gray900,
|
||||
tertiary = GoogleBlueDark,
|
||||
onTertiary = White,
|
||||
tertiaryContainer = GoogleBlueLight,
|
||||
onTertiaryContainer = Gray900,
|
||||
error = ErrorRed,
|
||||
onError = White,
|
||||
errorContainer = ErrorRedLight,
|
||||
onErrorContainer = Gray900,
|
||||
background = White,
|
||||
onBackground = Gray900,
|
||||
surface = White,
|
||||
onSurface = Gray900,
|
||||
surfaceVariant = Gray100,
|
||||
onSurfaceVariant = Gray700,
|
||||
outline = Gray200,
|
||||
outlineVariant = Gray100,
|
||||
scrim = Black
|
||||
)
|
||||
|
||||
/**
|
||||
* Dark color scheme using Google Blue and Green from app icon
|
||||
*/
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = GoogleBlueLight,
|
||||
onPrimary = Gray900,
|
||||
primaryContainer = GoogleBlueDark,
|
||||
onPrimaryContainer = Gray100,
|
||||
secondary = GoogleGreen,
|
||||
onSecondary = Gray900,
|
||||
secondaryContainer = GoogleGreenDark,
|
||||
onSecondaryContainer = Gray100,
|
||||
tertiary = GoogleBlue,
|
||||
onTertiary = Gray900,
|
||||
tertiaryContainer = GoogleBlueDark,
|
||||
onTertiaryContainer = Gray100,
|
||||
error = ErrorRedLight,
|
||||
onError = Gray900,
|
||||
errorContainer = ErrorRed,
|
||||
onErrorContainer = Gray100,
|
||||
background = Gray900,
|
||||
onBackground = Gray100,
|
||||
surface = Gray900,
|
||||
onSurface = Gray100,
|
||||
surfaceVariant = Gray700,
|
||||
onSurfaceVariant = Gray200,
|
||||
outline = Gray700,
|
||||
outlineVariant = Gray700,
|
||||
scrim = Black
|
||||
)
|
||||
|
||||
/**
|
||||
* SimpleLogUpload theme with Google Blue/Green branding
|
||||
*
|
||||
* @param darkTheme Whether to use dark theme (follows system by default)
|
||||
* @param content Composable content to apply theme to
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleLogUploadTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.mattintech.simplelogupload.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Material 3 default typography works well
|
||||
// Can be customized if specific fonts are needed
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
package com.mattintech.simplelogupload.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.NetworkConstants
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Manages chunked uploads for large files using Kotlin coroutines.
|
||||
* This class handles splitting a large file into chunks, uploading them,
|
||||
* tracking progress, and reassembling on the server.
|
||||
*
|
||||
* Key improvements over Java version:
|
||||
* - Uses Dispatchers.IO for all file operations to prevent ANR
|
||||
* - Uses coroutines instead of CompletableFuture for cleaner async code
|
||||
* - Properly handles threading to never block the main thread
|
||||
*/
|
||||
class ChunkedUploadManager(
|
||||
context: Context,
|
||||
private val file: File,
|
||||
private val maxDownloads: Int,
|
||||
private val expiryHours: Int
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = AppConstants.APP_TAG + "ChunkedUploadManager"
|
||||
|
||||
// Chunk size configuration
|
||||
private const val WIFI_CHUNK_SIZE = 10 * 1024 * 1024 // 10MB for WiFi
|
||||
private const val MOBILE_CHUNK_SIZE = 4 * 1024 * 1024 // 4MB for mobile networks
|
||||
|
||||
// Max parallel uploads
|
||||
private const val WIFI_MAX_PARALLEL = 3
|
||||
private const val MOBILE_MAX_PARALLEL = 2
|
||||
}
|
||||
|
||||
// Context and network
|
||||
private val context: Context = context.applicationContext
|
||||
private var isWifiConnection: Boolean = false
|
||||
private var networkCallback: NetworkCallback? = null
|
||||
private val connectivityManager: ConnectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
// Upload parameters
|
||||
private val chunkSize: Int
|
||||
private val totalChunks: Int
|
||||
private val maxParallelUploads: Int
|
||||
private val totalBytes: Long = file.length()
|
||||
|
||||
// Upload state
|
||||
private var uploadId: String = UUID.randomUUID().toString()
|
||||
private val completedChunks = mutableSetOf<Int>()
|
||||
private val inProgressChunks = mutableSetOf<Int>()
|
||||
private val isPaused = AtomicBoolean(false)
|
||||
private val isCancelled = AtomicBoolean(false)
|
||||
private val activeUploads = AtomicInteger(0)
|
||||
private val uploadedBytes = AtomicLong(0)
|
||||
|
||||
// Progress tracking
|
||||
private var progressListener: UploadProgressListener? = null
|
||||
|
||||
// OkHttpClient for uploads
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
// Coroutine scope for upload operations
|
||||
private val uploadScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
init {
|
||||
// Determine initial connection type
|
||||
detectConnectionType()
|
||||
|
||||
// Configure chunk size based on connection type
|
||||
chunkSize = if (isWifiConnection) WIFI_CHUNK_SIZE else MOBILE_CHUNK_SIZE
|
||||
|
||||
// Configure max parallel uploads based on connection type and user preference
|
||||
val parallelEnabled = PreferencesUtil.isParallelUploadsEnabled(context)
|
||||
maxParallelUploads = when {
|
||||
isWifiConnection -> WIFI_MAX_PARALLEL
|
||||
parallelEnabled -> MOBILE_MAX_PARALLEL
|
||||
else -> 1
|
||||
}
|
||||
|
||||
// Calculate total chunks
|
||||
totalChunks = ceil(file.length().toDouble() / chunkSize).toInt()
|
||||
|
||||
// Setup HTTP client
|
||||
setupHttpClient()
|
||||
|
||||
Log.d(TAG, "Created chunked upload manager for file: ${file.name}")
|
||||
Log.d(TAG, "Total size: $totalBytes bytes, Chunks: $totalChunks")
|
||||
Log.d(TAG, "Connection type: ${if (isWifiConnection) "WiFi" else "Mobile"}, " +
|
||||
"Chunk size: $chunkSize bytes, " +
|
||||
"Parallel uploads: $maxParallelUploads ${if (parallelEnabled) "(enabled)" else "(disabled)"}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tracking upload progress
|
||||
*/
|
||||
interface UploadProgressListener {
|
||||
fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int)
|
||||
fun onChunkComplete(chunkIndex: Int, totalChunks: Int)
|
||||
fun onUploadComplete(result: UploadResult)
|
||||
fun onError(error: String)
|
||||
fun onPaused()
|
||||
fun onResumed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a listener for upload progress events
|
||||
*/
|
||||
fun setProgressListener(listener: UploadProgressListener) {
|
||||
this.progressListener = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the upload process
|
||||
* Maintains CompletableFuture API for compatibility with existing code
|
||||
*/
|
||||
fun startUpload(): CompletableFuture<UploadResult> {
|
||||
val future = CompletableFuture<UploadResult>()
|
||||
|
||||
uploadScope.launch {
|
||||
try {
|
||||
// Reset state
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.clear()
|
||||
}
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.clear()
|
||||
}
|
||||
uploadedBytes.set(0)
|
||||
isPaused.set(false)
|
||||
isCancelled.set(false)
|
||||
|
||||
// Start network monitoring
|
||||
withContext(Dispatchers.Main) {
|
||||
registerNetworkCallback()
|
||||
}
|
||||
|
||||
// Initialize upload session
|
||||
val sessionId = initializeUploadSession()
|
||||
if (sessionId.isNullOrEmpty()) {
|
||||
future.complete(UploadResult("Failed to initialize upload session"))
|
||||
return@launch
|
||||
}
|
||||
|
||||
uploadId = sessionId
|
||||
Log.d(TAG, "Upload session initialized with ID: $uploadId")
|
||||
|
||||
// Upload all chunks
|
||||
val isComplete = uploadAllChunks()
|
||||
|
||||
if (isComplete) {
|
||||
// Finalize the upload
|
||||
val result = finalizeUpload()
|
||||
future.complete(result)
|
||||
} else {
|
||||
future.complete(UploadResult("Upload incomplete or cancelled"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during upload", e)
|
||||
future.complete(UploadResult("Upload error: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
return future
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an upload session with the server
|
||||
*/
|
||||
private suspend fun initializeUploadSession(): String? = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val initUrl = "$serverBaseUrl/upload/start"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("filename", file.name)
|
||||
.addFormDataPart("filesize", totalBytes.toString())
|
||||
.addFormDataPart("chunk_size", chunkSize.toString())
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart("max_downloads", maxDownloads.toString())
|
||||
.addFormDataPart("expiry_hours", expiryHours.toString())
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(initUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Initializing upload session...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to initialize upload session", e)
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val sessionId = jsonResponse.getString("upload_id")
|
||||
continuation.resume(sessionId)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse session initialization response", e)
|
||||
continuation.resume(null)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Session initialization failed with code: ${response.code}")
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all chunks to the server
|
||||
* Uses coroutines to manage parallel uploads efficiently
|
||||
*/
|
||||
private suspend fun uploadAllChunks(): Boolean = coroutineScope {
|
||||
val deferredResults = mutableListOf<Deferred<Boolean>>()
|
||||
|
||||
while (true) {
|
||||
if (isCancelled.get()) {
|
||||
return@coroutineScope false
|
||||
}
|
||||
|
||||
if (isPaused.get()) {
|
||||
delay(100)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if all chunks are complete
|
||||
synchronized(completedChunks) {
|
||||
if (completedChunks.size == totalChunks) {
|
||||
val allChunksPresent = (0 until totalChunks).all { it in completedChunks }
|
||||
if (allChunksPresent) {
|
||||
Log.d(TAG, "All chunks uploaded successfully")
|
||||
return@coroutineScope true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find next chunks to upload
|
||||
val chunksToUpload = mutableListOf<Int>()
|
||||
synchronized(completedChunks) {
|
||||
synchronized(inProgressChunks) {
|
||||
for (i in 0 until totalChunks) {
|
||||
if (activeUploads.get() >= maxParallelUploads) break
|
||||
if (i !in completedChunks && i !in inProgressChunks) {
|
||||
chunksToUpload.add(i)
|
||||
inProgressChunks.add(i)
|
||||
activeUploads.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch uploads for new chunks
|
||||
for (chunkIndex in chunksToUpload) {
|
||||
val deferred = async {
|
||||
try {
|
||||
val success = uploadChunk(chunkIndex)
|
||||
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.remove(chunkIndex)
|
||||
}
|
||||
activeUploads.decrementAndGet()
|
||||
|
||||
if (success) {
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.add(chunkIndex)
|
||||
}
|
||||
progressListener?.onChunkComplete(chunkIndex, totalChunks)
|
||||
} else if (!isCancelled.get() && !isPaused.get()) {
|
||||
// Retry failed chunks
|
||||
Log.d(TAG, "Chunk $chunkIndex failed, will retry")
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.remove(chunkIndex)
|
||||
}
|
||||
}
|
||||
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading chunk $chunkIndex", e)
|
||||
synchronized(inProgressChunks) {
|
||||
inProgressChunks.remove(chunkIndex)
|
||||
}
|
||||
activeUploads.decrementAndGet()
|
||||
false
|
||||
}
|
||||
}
|
||||
deferredResults.add(deferred)
|
||||
}
|
||||
|
||||
// Clean up completed deferreds
|
||||
deferredResults.removeAll { it.isCompleted }
|
||||
|
||||
// Check if we're stalled
|
||||
synchronized(completedChunks) {
|
||||
synchronized(inProgressChunks) {
|
||||
if (activeUploads.get() == 0 && inProgressChunks.isEmpty() &&
|
||||
completedChunks.size < totalChunks && !isPaused.get()) {
|
||||
Log.e(TAG, "Upload stalled with ${completedChunks.size}/$totalChunks chunks completed")
|
||||
return@coroutineScope false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay before checking again
|
||||
delay(100)
|
||||
}
|
||||
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
false // This line is unreachable but needed for type inference
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single chunk to the server
|
||||
* CRITICAL: Uses Dispatchers.IO for file reading to prevent ANR
|
||||
*/
|
||||
private suspend fun uploadChunk(chunkIndex: Int): Boolean = withContext(Dispatchers.IO) {
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val chunkUrl = "$serverBaseUrl/upload/chunk"
|
||||
|
||||
try {
|
||||
// Calculate chunk boundaries
|
||||
val start = chunkIndex.toLong() * chunkSize
|
||||
val end = min(start + chunkSize, file.length())
|
||||
val currentChunkSize = (end - start).toInt()
|
||||
|
||||
Log.d(TAG, "Uploading chunk $chunkIndex ($currentChunkSize bytes)")
|
||||
|
||||
// Read chunk data on IO dispatcher - THIS FIXES THE ANR!
|
||||
val buffer = ByteArray(currentChunkSize)
|
||||
FileInputStream(file).use { inputStream ->
|
||||
inputStream.skip(start)
|
||||
val bytesRead = inputStream.read(buffer)
|
||||
if (bytesRead != currentChunkSize) {
|
||||
Log.e(TAG, "Failed to read chunk data: expected $currentChunkSize bytes, got $bytesRead")
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
// Build request
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.addFormDataPart("chunk_index", chunkIndex.toString())
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart(
|
||||
"chunk",
|
||||
"${file.name}.part$chunkIndex",
|
||||
buffer.toRequestBody("application/octet-stream".toMediaType())
|
||||
)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(chunkUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
// Execute request (suspend until complete)
|
||||
val response = client.newCall(request).await()
|
||||
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val success = jsonResponse.optBoolean("success", false)
|
||||
val confirmedChunkIndex = jsonResponse.optInt("chunk_index", -1)
|
||||
|
||||
if (success && (confirmedChunkIndex == chunkIndex || confirmedChunkIndex == -1)) {
|
||||
// Update progress
|
||||
val newTotal = uploadedBytes.addAndGet(currentChunkSize.toLong())
|
||||
val percentComplete = (newTotal * 100 / totalBytes).toInt()
|
||||
|
||||
// Notify listener on main thread
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onProgress(newTotal, totalBytes, percentComplete)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Chunk $chunkIndex uploaded successfully. Progress: $percentComplete%")
|
||||
return@withContext true
|
||||
} else {
|
||||
Log.e(TAG, "Server reported issue with chunk $chunkIndex: $responseBody")
|
||||
return@withContext false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing chunk upload response for chunk $chunkIndex", e)
|
||||
return@withContext false
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Chunk $chunkIndex upload failed with code: ${response.code}, Body: $errorBody")
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error preparing chunk $chunkIndex", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert OkHttp Call to suspend function
|
||||
*/
|
||||
private suspend fun Call.await(): Response = suspendCoroutine { continuation ->
|
||||
enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify which chunks the server has received
|
||||
*/
|
||||
private suspend fun verifyChunksWithServer(): Set<Int> = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val verifyUrl = "$serverBaseUrl/upload/verify"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(verifyUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Verifying uploaded chunks with server...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to verify chunks with server", e)
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val missingChunks = mutableSetOf<Int>()
|
||||
|
||||
if (jsonResponse.has("missing_chunks")) {
|
||||
val missingArray = jsonResponse.getJSONArray("missing_chunks")
|
||||
for (i in 0 until missingArray.length()) {
|
||||
missingChunks.add(missingArray.getInt(i))
|
||||
}
|
||||
Log.d(TAG, "Server missing chunks: $missingChunks")
|
||||
}
|
||||
|
||||
continuation.resume(missingChunks)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing chunk verification response", e)
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Chunk verification failed with code: ${response.code}, Body: $errorBody")
|
||||
continuation.resume(emptySet())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry uploading specific chunks
|
||||
*/
|
||||
private suspend fun retrySpecificChunks(chunkIndexes: Set<Int>): Boolean = coroutineScope {
|
||||
if (chunkIndexes.isEmpty()) return@coroutineScope true
|
||||
|
||||
Log.d(TAG, "Retrying specific chunks: $chunkIndexes")
|
||||
|
||||
val results = chunkIndexes.map { chunkIndex ->
|
||||
async {
|
||||
Log.d(TAG, "Retrying upload of chunk $chunkIndex")
|
||||
val success = uploadChunk(chunkIndex)
|
||||
if (success) {
|
||||
Log.d(TAG, "Successfully reuploaded chunk $chunkIndex")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to reupload chunk $chunkIndex")
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
|
||||
val allSuccessful = results.awaitAll().all { it }
|
||||
Log.d(TAG, "All chunk retries completed. ${results.count { it.await() }}/${results.size} successful")
|
||||
|
||||
return@coroutineScope allSuccessful
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the upload on the server
|
||||
*/
|
||||
private suspend fun finalizeUpload(): UploadResult {
|
||||
// Verify all chunks are uploaded
|
||||
val missingChunks = synchronized(completedChunks) {
|
||||
(0 until totalChunks).filter { it !in completedChunks }.toSet()
|
||||
}
|
||||
|
||||
if (missingChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Cannot finalize: missing chunks $missingChunks")
|
||||
return UploadResult("Cannot finalize: missing chunks $missingChunks")
|
||||
}
|
||||
|
||||
// Verify with server
|
||||
val serverMissingChunks = verifyChunksWithServer()
|
||||
if (serverMissingChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Server missing chunks: $serverMissingChunks, reuploading")
|
||||
|
||||
// Subtract bytes for missing chunks
|
||||
for (chunkIndex in serverMissingChunks) {
|
||||
synchronized(completedChunks) {
|
||||
completedChunks.remove(chunkIndex)
|
||||
}
|
||||
val start = chunkIndex.toLong() * chunkSize
|
||||
val end = min(start + chunkSize, file.length())
|
||||
val chunkBytes = end - start
|
||||
uploadedBytes.addAndGet(-chunkBytes)
|
||||
}
|
||||
|
||||
// Retry missing chunks
|
||||
val retrySuccess = retrySpecificChunks(serverMissingChunks)
|
||||
if (!retrySuccess) {
|
||||
Log.e(TAG, "Failed to reupload missing chunks")
|
||||
return UploadResult("Failed to reupload missing chunks")
|
||||
}
|
||||
Log.d(TAG, "Missing chunks reuploaded, finalizing")
|
||||
}
|
||||
|
||||
// Perform finalize request
|
||||
return performFinalizeRequest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the finalize request to the server
|
||||
*/
|
||||
private suspend fun performFinalizeRequest(): UploadResult = suspendCoroutine { continuation ->
|
||||
val serverBaseUrl = PreferencesUtil.getServerBaseUrl(context)
|
||||
val clientKey = PreferencesUtil.getClientKey(context)
|
||||
val finalizeUrl = "$serverBaseUrl/upload/complete"
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("upload_id", uploadId)
|
||||
.addFormDataPart("filename", file.name)
|
||||
.addFormDataPart("total_chunks", totalChunks.toString())
|
||||
.addFormDataPart("max_downloads", maxDownloads.toString())
|
||||
.addFormDataPart("expiry_hours", expiryHours.toString())
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(finalizeUrl)
|
||||
.header(NetworkConstants.CLIENT_KEY_HEADER, clientKey)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Finalizing upload...")
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Failed to finalize upload", e)
|
||||
continuation.resume(UploadResult("Failed to finalize upload: ${e.message}"))
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (response.isSuccessful) {
|
||||
try {
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
Log.d(TAG, "Server response: $responseBody")
|
||||
|
||||
val jsonResponse = JSONObject(responseBody)
|
||||
val uploadCode = jsonResponse.getString("code")
|
||||
|
||||
Log.d(TAG, "Upload finalized successfully! Code: $uploadCode")
|
||||
|
||||
val result = UploadResult(uploadCode, file.name, maxDownloads, expiryHours)
|
||||
progressListener?.onUploadComplete(result)
|
||||
|
||||
continuation.resume(result)
|
||||
} catch (e: JSONException) {
|
||||
Log.e(TAG, "Failed to parse server response", e)
|
||||
continuation.resume(UploadResult("Failed to parse server response"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.body?.string() ?: ""
|
||||
Log.e(TAG, "Finalize failed with code: ${response.code}, Body: $errorBody")
|
||||
continuation.resume(UploadResult("Failed to finalize upload: ${response.code}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the upload
|
||||
*/
|
||||
fun pauseUpload() {
|
||||
if (!isPaused.getAndSet(true)) {
|
||||
Log.d(TAG, "Upload paused")
|
||||
progressListener?.onPaused()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the upload
|
||||
* Maintains CompletableFuture API for compatibility
|
||||
*/
|
||||
fun resumeUpload() {
|
||||
if (isPaused.getAndSet(false)) {
|
||||
Log.d(TAG, "Upload resumed")
|
||||
progressListener?.onResumed()
|
||||
|
||||
// Launch resume in upload scope
|
||||
uploadScope.launch {
|
||||
try {
|
||||
val isComplete = uploadAllChunks()
|
||||
if (isComplete) {
|
||||
val result = finalizeUpload()
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onUploadComplete(result)
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onError("Upload incomplete or cancelled")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during chunk uploading", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
progressListener?.onError("Chunk upload error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the upload
|
||||
*/
|
||||
fun cancelUpload() {
|
||||
isCancelled.set(true)
|
||||
Log.d(TAG, "Upload cancelled")
|
||||
|
||||
// Cancel all coroutines
|
||||
uploadScope.cancel()
|
||||
|
||||
// Unregister network callback
|
||||
unregisterNetworkCallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup HTTP client with timeouts
|
||||
*/
|
||||
private fun setupHttpClient() {
|
||||
val connectionTimeout = if (isWifiConnection)
|
||||
NetworkConstants.CONNECTION_TIMEOUT
|
||||
else
|
||||
NetworkConstants.CONNECTION_TIMEOUT / 2
|
||||
|
||||
val writeTimeout = if (isWifiConnection)
|
||||
NetworkConstants.WRITE_TIMEOUT
|
||||
else
|
||||
NetworkConstants.WRITE_TIMEOUT / 2
|
||||
|
||||
val readTimeout = if (isWifiConnection)
|
||||
NetworkConstants.READ_TIMEOUT
|
||||
else
|
||||
NetworkConstants.READ_TIMEOUT / 2
|
||||
|
||||
client = OkHttpClient.Builder()
|
||||
.connectTimeout(connectionTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.writeTimeout(writeTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.readTimeout(readTimeout.toLong(), TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect current connection type (WiFi or Mobile)
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun detectConnectionType() {
|
||||
val activeNetwork = connectivityManager.activeNetworkInfo
|
||||
isWifiConnection = activeNetwork != null &&
|
||||
activeNetwork.type == ConnectivityManager.TYPE_WIFI
|
||||
|
||||
Log.d(TAG, "Connection type detected: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Register network callback to monitor connection changes
|
||||
*/
|
||||
private fun registerNetworkCallback() {
|
||||
if (networkCallback == null) {
|
||||
networkCallback = NetworkCallback()
|
||||
}
|
||||
|
||||
val builder = NetworkRequest.Builder()
|
||||
connectivityManager.registerNetworkCallback(builder.build(), networkCallback!!)
|
||||
|
||||
Log.d(TAG, "Network callback registered")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister network callback
|
||||
*/
|
||||
private fun unregisterNetworkCallback() {
|
||||
networkCallback?.let {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(it)
|
||||
Log.d(TAG, "Network callback unregistered")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network callback for handling connection changes
|
||||
*/
|
||||
private inner class NetworkCallback : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
val wasWifi = isWifiConnection
|
||||
detectConnectionType()
|
||||
|
||||
Log.d(TAG, "Network available: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
|
||||
// Resume if paused due to network loss
|
||||
if (isPaused.get()) {
|
||||
Log.d(TAG, "Network restored, resuming upload")
|
||||
resumeUpload()
|
||||
}
|
||||
|
||||
// Adjust settings if connection type changed
|
||||
if (wasWifi != isWifiConnection) {
|
||||
Log.d(TAG, "Connection type changed, adjusting settings")
|
||||
setupHttpClient()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
Log.d(TAG, "Network lost")
|
||||
pauseUpload()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
super.onCapabilitiesChanged(network, capabilities)
|
||||
|
||||
val newIsWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|
||||
if (newIsWifi != isWifiConnection) {
|
||||
isWifiConnection = newIsWifi
|
||||
Log.d(TAG, "Connection capabilities changed to: ${if (isWifiConnection) "WiFi" else "Mobile"}")
|
||||
setupHttpClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.mattintech.simplelogupload.upload
|
||||
|
||||
/**
|
||||
* Callback interface for tracking upload progress
|
||||
*/
|
||||
interface UploadProgressCallback {
|
||||
/**
|
||||
* Called when upload progress changes
|
||||
*
|
||||
* @param bytesUploaded Number of bytes uploaded so far
|
||||
* @param totalBytes Total number of bytes to upload
|
||||
* @param percentComplete Percentage complete (0-100)
|
||||
*/
|
||||
fun onProgress(bytesUploaded: Long, totalBytes: Long, percentComplete: Int)
|
||||
|
||||
/**
|
||||
* Called when a chunk upload completes (for chunked uploads)
|
||||
*
|
||||
* @param chunkIndex Index of the completed chunk
|
||||
* @param totalChunks Total number of chunks
|
||||
*/
|
||||
fun onChunkComplete(chunkIndex: Int, totalChunks: Int)
|
||||
|
||||
/**
|
||||
* Called for general status updates
|
||||
*
|
||||
* @param status Status message
|
||||
*/
|
||||
fun onStatusUpdate(status: String)
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import java.io.File;
|
||||
public class UploadStrategySelector {
|
||||
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
|
||||
|
||||
// Threshold for using chunked uploads
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile
|
||||
// Threshold for using chunked uploads - matches chunk sizes
|
||||
// Only use chunked upload if file is larger than chunk size
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 10 * 1024 * 1024; // 10MB for WiFi (matches chunk size)
|
||||
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 4 * 1024 * 1024; // 4MB for mobile (matches chunk size)
|
||||
|
||||
/**
|
||||
* Check if chunked uploads should be used for the given file
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,24 @@ public class PreferencesUtil {
|
||||
public static final String KEY_SERVER_PORT = "server_port";
|
||||
public static final String KEY_CLIENT_KEY = "client_key";
|
||||
public static final String KEY_SETUP_COMPLETED = "setup_completed";
|
||||
public static final String KEY_ONBOARDING_COMPLETED = "onboarding_completed";
|
||||
public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads";
|
||||
public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours";
|
||||
public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload";
|
||||
public static final String KEY_UPLOAD_IN_PROGRESS = "upload_in_progress";
|
||||
public static final String KEY_UPLOAD_FILE_NAME = "upload_file_name";
|
||||
public static final String KEY_ENABLE_PARALLEL_UPLOADS = "enable_parallel_uploads";
|
||||
|
||||
// Default values
|
||||
private static final String DEFAULT_SERVER_ADDRESS = "";
|
||||
private static final int DEFAULT_SERVER_PORT = 0;
|
||||
private static final String DEFAULT_CLIENT_KEY = "";
|
||||
private static final boolean DEFAULT_SETUP_COMPLETED = false;
|
||||
private static final boolean DEFAULT_ONBOARDING_COMPLETED = false;
|
||||
private static final int DEFAULT_MAX_DOWNLOADS = 5;
|
||||
private static final int DEFAULT_EXPIRY_HOURS = 24;
|
||||
private static final boolean DEFAULT_ASK_BEFORE_UPLOAD = true;
|
||||
private static final boolean DEFAULT_ENABLE_PARALLEL_UPLOADS = false;
|
||||
|
||||
/**
|
||||
* Get the shared preferences instance
|
||||
@@ -126,6 +138,11 @@ public class PreferencesUtil {
|
||||
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
|
||||
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
|
||||
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED);
|
||||
editor.putBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||
editor.putInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||
editor.putInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||
editor.putBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||
editor.putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
@@ -149,6 +166,86 @@ public class PreferencesUtil {
|
||||
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if onboarding has been completed, false otherwise
|
||||
*/
|
||||
public static boolean isOnboardingCompleted(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the onboarding completed status
|
||||
*
|
||||
* @param context The application context
|
||||
* @param completed True if onboarding has been completed, false otherwise
|
||||
*/
|
||||
public static void setOnboardingCompleted(Context context, boolean completed) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default max downloads from preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The default max downloads
|
||||
*/
|
||||
public static int getDefaultMaxDownloads(Context context) {
|
||||
return getPreferences(context).getInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default max downloads in preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @param maxDownloads The default max downloads to save
|
||||
*/
|
||||
public static void setDefaultMaxDownloads(Context context, int maxDownloads) {
|
||||
getPreferences(context).edit().putInt(KEY_DEFAULT_MAX_DOWNLOADS, maxDownloads).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default expiry hours from preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The default expiry hours
|
||||
*/
|
||||
public static int getDefaultExpiryHours(Context context) {
|
||||
return getPreferences(context).getInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default expiry hours in preferences
|
||||
*
|
||||
* @param context The application context
|
||||
* @param expiryHours The default expiry hours to save
|
||||
*/
|
||||
public static void setDefaultExpiryHours(Context context, int expiryHours) {
|
||||
getPreferences(context).edit().putInt(KEY_DEFAULT_EXPIRY_HOURS, expiryHours).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ask before upload preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if should ask before upload, false otherwise
|
||||
*/
|
||||
public static boolean getAskBeforeUpload(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ask before upload preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @param askBeforeUpload True if should ask before upload, false otherwise
|
||||
*/
|
||||
public static void setAskBeforeUpload(Context context, boolean askBeforeUpload) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ASK_BEFORE_UPLOAD, askBeforeUpload).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required server settings are configured
|
||||
*
|
||||
@@ -163,4 +260,62 @@ public class PreferencesUtil {
|
||||
|
||||
return !address.isEmpty() && port > 0 && !clientKey.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an upload is currently in progress
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if an upload is in progress, false otherwise
|
||||
*/
|
||||
public static boolean isUploadInProgress(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_UPLOAD_IN_PROGRESS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the upload in progress status
|
||||
*
|
||||
* @param context The application context
|
||||
* @param inProgress True if upload is in progress, false otherwise
|
||||
* @param fileName The name of the file being uploaded (optional)
|
||||
*/
|
||||
public static void setUploadInProgress(Context context, boolean inProgress, String fileName) {
|
||||
SharedPreferences.Editor editor = getPreferences(context).edit();
|
||||
editor.putBoolean(KEY_UPLOAD_IN_PROGRESS, inProgress);
|
||||
if (inProgress && fileName != null) {
|
||||
editor.putString(KEY_UPLOAD_FILE_NAME, fileName);
|
||||
} else if (!inProgress) {
|
||||
editor.remove(KEY_UPLOAD_FILE_NAME);
|
||||
}
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file currently being uploaded
|
||||
*
|
||||
* @param context The application context
|
||||
* @return The file name, or null if no upload is in progress
|
||||
*/
|
||||
public static String getUploadFileName(Context context) {
|
||||
return getPreferences(context).getString(KEY_UPLOAD_FILE_NAME, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parallel uploads enabled preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @return True if parallel uploads are enabled, false otherwise
|
||||
*/
|
||||
public static boolean isParallelUploadsEnabled(Context context) {
|
||||
return getPreferences(context).getBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the parallel uploads enabled preference
|
||||
*
|
||||
* @param context The application context
|
||||
* @param enabled True to enable parallel uploads, false otherwise
|
||||
*/
|
||||
public static void setParallelUploadsEnabled(Context context, boolean enabled) {
|
||||
getPreferences(context).edit().putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, enabled).apply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package com.mattintech.simplelogupload.util;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.constant.FileConstants;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* Utility class for zipping files
|
||||
*/
|
||||
public class ZipUtil {
|
||||
private static final String TAG = AppConstants.APP_TAG + "ZipUtil";
|
||||
private static final int BUFFER_SIZE = 8192; // 8KB buffer
|
||||
|
||||
/**
|
||||
* Interface for tracking zip progress
|
||||
*/
|
||||
public interface ZipProgressListener {
|
||||
void onProgress(long bytesProcessed, long totalBytes);
|
||||
void onComplete(File zipFile);
|
||||
void onError(Exception e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a file into a zip archive
|
||||
*
|
||||
* @param sourceFile The file to compress
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
public static File zipFile(File sourceFile) throws IOException {
|
||||
return zipFile(sourceFile, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a file into a zip archive with progress tracking
|
||||
*
|
||||
* @param sourceFile The file to compress
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
public static File zipFile(File sourceFile, ZipProgressListener listener) throws IOException {
|
||||
if (sourceFile == null || !sourceFile.exists()) {
|
||||
String msg = "Source file does not exist: " + (sourceFile != null ? sourceFile.getAbsolutePath() : "null");
|
||||
Log.e(TAG, msg);
|
||||
throw new IOException(msg);
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(sourceFile)) {
|
||||
Log.d(TAG, "File is already a zip file: " + sourceFile.getName());
|
||||
if (listener != null) {
|
||||
listener.onComplete(sourceFile);
|
||||
}
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
// Create a zip file in the same directory
|
||||
String zipFileName = sourceFile.getName() + FileConstants.ZIP_EXTENSION;
|
||||
File zipFile = new File(sourceFile.getParentFile(), zipFileName);
|
||||
|
||||
Log.d(TAG, "Creating zip file: " + zipFile.getAbsolutePath());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(zipFile);
|
||||
BufferedOutputStream bos = new BufferedOutputStream(fos);
|
||||
ZipOutputStream zos = new ZipOutputStream(bos);
|
||||
FileInputStream fis = new FileInputStream(sourceFile);
|
||||
BufferedInputStream bis = new BufferedInputStream(fis)) {
|
||||
|
||||
ZipEntry zipEntry = new ZipEntry(sourceFile.getName());
|
||||
zipEntry.setSize(sourceFile.length());
|
||||
zos.putNextEntry(zipEntry);
|
||||
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int len;
|
||||
long bytesProcessed = 0;
|
||||
long totalBytes = sourceFile.length();
|
||||
|
||||
while ((len = bis.read(buffer)) > 0) {
|
||||
zos.write(buffer, 0, len);
|
||||
bytesProcessed += len;
|
||||
|
||||
if (listener != null) {
|
||||
listener.onProgress(bytesProcessed, totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
zos.closeEntry();
|
||||
|
||||
if (listener != null) {
|
||||
listener.onComplete(zipFile);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Successfully created zip file: " + zipFile.getName());
|
||||
return zipFile;
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error creating zip file", e);
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
boolean deleted = zipFile.delete();
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: " + zipFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
listener.onError(e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses multiple files into a single zip archive
|
||||
*
|
||||
* @param sourceFiles List of files to compress
|
||||
* @param outputDirectory Directory to store the zip file
|
||||
* @param zipFileName Name of the zip file to create
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
public static File zipFiles(List<File> sourceFiles, File outputDirectory, String zipFileName,
|
||||
ZipProgressListener listener) throws IOException {
|
||||
if (sourceFiles == null || sourceFiles.isEmpty()) {
|
||||
String msg = "No source files provided for zipping";
|
||||
Log.e(TAG, msg);
|
||||
throw new IOException(msg);
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if (!outputDirectory.exists()) {
|
||||
if (!outputDirectory.mkdirs()) {
|
||||
throw new IOException("Could not create output directory: " + outputDirectory.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the zip extension
|
||||
if (!zipFileName.toLowerCase().endsWith(FileConstants.ZIP_EXTENSION)) {
|
||||
zipFileName += FileConstants.ZIP_EXTENSION;
|
||||
}
|
||||
|
||||
// Create a zip file in the output directory
|
||||
File zipFile = new File(outputDirectory, zipFileName);
|
||||
|
||||
Log.d(TAG, "Creating multi-file zip: " + zipFile.getAbsolutePath() + " with " + sourceFiles.size() + " files");
|
||||
|
||||
// Calculate total size for progress reporting
|
||||
long totalBytes = 0;
|
||||
for (File file : sourceFiles) {
|
||||
if (file.exists()) {
|
||||
totalBytes += file.length();
|
||||
}
|
||||
}
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(zipFile);
|
||||
BufferedOutputStream bos = new BufferedOutputStream(fos);
|
||||
ZipOutputStream zos = new ZipOutputStream(bos)) {
|
||||
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long bytesProcessed = 0;
|
||||
|
||||
for (File file : sourceFiles) {
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
Log.w(TAG, "Skipping non-existent or non-file: " + file.getAbsolutePath());
|
||||
continue;
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(file);
|
||||
BufferedInputStream bis = new BufferedInputStream(fis)) {
|
||||
|
||||
ZipEntry zipEntry = new ZipEntry(file.getName());
|
||||
zipEntry.setSize(file.length());
|
||||
zos.putNextEntry(zipEntry);
|
||||
|
||||
int len;
|
||||
while ((len = bis.read(buffer)) > 0) {
|
||||
zos.write(buffer, 0, len);
|
||||
bytesProcessed += len;
|
||||
|
||||
if (listener != null) {
|
||||
listener.onProgress(bytesProcessed, totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
listener.onComplete(zipFile);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Successfully created multi-file zip: " + zipFile.getName());
|
||||
return zipFile;
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error creating multi-file zip", e);
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
boolean deleted = zipFile.delete();
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: " + zipFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
listener.onError(e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal file
239
app/src/main/java/com/mattintech/simplelogupload/util/ZipUtil.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.mattintech.simplelogupload.util
|
||||
|
||||
import android.util.Log
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.constant.FileConstants
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Utility class for zipping files using Kotlin coroutines
|
||||
* CRITICAL: All file I/O operations run on Dispatchers.IO to prevent ANR
|
||||
*/
|
||||
object ZipUtil {
|
||||
private const val TAG = AppConstants.APP_TAG + "ZipUtil"
|
||||
private const val BUFFER_SIZE = 8192 // 8KB buffer
|
||||
|
||||
/**
|
||||
* Interface for tracking zip progress
|
||||
*/
|
||||
interface ZipProgressListener {
|
||||
fun onProgress(bytesProcessed: Long, totalBytes: Long)
|
||||
fun onComplete(zipFile: File)
|
||||
fun onError(e: Exception)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a file into a zip archive
|
||||
* Runs on Dispatchers.IO to prevent ANR
|
||||
* Checks for cancellation during compression
|
||||
*
|
||||
* @param sourceFile The file to compress
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
@JvmStatic
|
||||
suspend fun zipFile(sourceFile: File, listener: ZipProgressListener? = null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!sourceFile.exists()) {
|
||||
val msg = "Source file does not exist: ${sourceFile.absolutePath}"
|
||||
Log.e(TAG, msg)
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
// If already a zip file, just return it
|
||||
if (FileUtil.isZipFile(sourceFile)) {
|
||||
Log.d(TAG, "File is already a zip file: ${sourceFile.name}")
|
||||
listener?.onComplete(sourceFile)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Create a zip file in the same directory
|
||||
val zipFileName = sourceFile.name + FileConstants.ZIP_EXTENSION
|
||||
val zipFile = File(sourceFile.parentFile, zipFileName)
|
||||
|
||||
Log.d(TAG, "Creating zip file: ${zipFile.absolutePath}")
|
||||
|
||||
try {
|
||||
FileOutputStream(zipFile).use { fos ->
|
||||
BufferedOutputStream(fos).use { bos ->
|
||||
ZipOutputStream(bos).use { zos ->
|
||||
FileInputStream(sourceFile).use { fis ->
|
||||
BufferedInputStream(fis).use { bis ->
|
||||
val zipEntry = ZipEntry(sourceFile.name)
|
||||
zipEntry.size = sourceFile.length()
|
||||
zos.putNextEntry(zipEntry)
|
||||
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytesProcessed = 0L
|
||||
val totalBytes = sourceFile.length()
|
||||
var len: Int
|
||||
var lastProgressUpdate = 0L
|
||||
|
||||
while (bis.read(buffer).also { len = it } > 0) {
|
||||
// Check for cancellation
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Zip operation cancelled by user")
|
||||
throw CancellationException("Zip operation cancelled")
|
||||
}
|
||||
|
||||
zos.write(buffer, 0, len)
|
||||
bytesProcessed += len
|
||||
|
||||
// Update progress every 100KB to avoid overwhelming UI
|
||||
if (bytesProcessed - lastProgressUpdate >= 100_000 || bytesProcessed == totalBytes) {
|
||||
lastProgressUpdate = bytesProcessed
|
||||
Log.d(TAG, "Zip progress: $bytesProcessed / $totalBytes bytes (${(bytesProcessed * 100 / totalBytes)}%)")
|
||||
listener?.onProgress(bytesProcessed, totalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onComplete(zipFile)
|
||||
Log.d(TAG, "Successfully created zip file: ${zipFile.name}")
|
||||
} catch (e: CancellationException) {
|
||||
// Clean up the partial zip file
|
||||
if (zipFile.exists()) {
|
||||
zipFile.delete()
|
||||
Log.d(TAG, "Deleted partial zip file after cancellation")
|
||||
}
|
||||
// Re-throw to cancel the coroutine properly
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating zip file", e)
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
val deleted = zipFile.delete()
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Don't treat cancellation as an error - just re-throw it
|
||||
Log.d(TAG, "Zip cancelled - cleaning up")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses multiple files into a single zip archive
|
||||
* Runs on Dispatchers.IO to prevent ANR
|
||||
*
|
||||
* @param sourceFiles List of files to compress
|
||||
* @param outputDirectory Directory to store the zip file
|
||||
* @param zipFileName Name of the zip file to create
|
||||
* @param listener A listener for tracking progress (can be null)
|
||||
* @return The compressed zip file
|
||||
* @throws IOException If compression fails
|
||||
*/
|
||||
@JvmStatic
|
||||
suspend fun zipFiles(
|
||||
sourceFiles: List<File>,
|
||||
outputDirectory: File,
|
||||
zipFileName: String,
|
||||
listener: ZipProgressListener? = null
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
if (sourceFiles.isEmpty()) {
|
||||
val msg = "No source files provided for zipping"
|
||||
Log.e(TAG, msg)
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if (!outputDirectory.exists()) {
|
||||
if (!outputDirectory.mkdirs()) {
|
||||
throw IOException("Could not create output directory: ${outputDirectory.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the zip extension
|
||||
val finalZipFileName = if (zipFileName.lowercase().endsWith(FileConstants.ZIP_EXTENSION)) {
|
||||
zipFileName
|
||||
} else {
|
||||
zipFileName + FileConstants.ZIP_EXTENSION
|
||||
}
|
||||
|
||||
// Create a zip file in the output directory
|
||||
val zipFile = File(outputDirectory, finalZipFileName)
|
||||
|
||||
Log.d(TAG, "Creating multi-file zip: ${zipFile.absolutePath} with ${sourceFiles.size} files")
|
||||
|
||||
// Calculate total size for progress reporting
|
||||
val totalBytes = sourceFiles.filter { it.exists() }.sumOf { it.length() }
|
||||
|
||||
try {
|
||||
FileOutputStream(zipFile).use { fos ->
|
||||
BufferedOutputStream(fos).use { bos ->
|
||||
ZipOutputStream(bos).use { zos ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var bytesProcessed = 0L
|
||||
|
||||
for (file in sourceFiles) {
|
||||
if (!file.exists() || !file.isFile) {
|
||||
Log.w(TAG, "Skipping non-existent or non-file: ${file.absolutePath}")
|
||||
continue
|
||||
}
|
||||
|
||||
FileInputStream(file).use { fis ->
|
||||
BufferedInputStream(fis).use { bis ->
|
||||
val zipEntry = ZipEntry(file.name)
|
||||
zipEntry.size = file.length()
|
||||
zos.putNextEntry(zipEntry)
|
||||
|
||||
var len: Int
|
||||
while (bis.read(buffer).also { len = it } > 0) {
|
||||
zos.write(buffer, 0, len)
|
||||
bytesProcessed += len
|
||||
|
||||
listener?.onProgress(bytesProcessed, totalBytes)
|
||||
}
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onComplete(zipFile)
|
||||
Log.d(TAG, "Successfully created multi-file zip: ${zipFile.name}")
|
||||
|
||||
return@withContext zipFile
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error creating multi-file zip", e)
|
||||
|
||||
// Clean up the partial zip file if it exists
|
||||
if (zipFile.exists()) {
|
||||
val deleted = zipFile.delete()
|
||||
if (!deleted) {
|
||||
Log.w(TAG, "Failed to delete partial zip file: ${zipFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
listener?.onError(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.adapter.DumpstateFileAdapter;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dialog for selecting a dumpstate file from multiple options
|
||||
*/
|
||||
public class DumpstateSelectionDialog extends Dialog {
|
||||
|
||||
private final List<File> dumpstateFiles;
|
||||
private final OnDumpstateSelectedListener listener;
|
||||
|
||||
private TextView titleText;
|
||||
private RecyclerView recyclerView;
|
||||
private TextView emptyView;
|
||||
private Button selectButton;
|
||||
private Button cancelButton;
|
||||
|
||||
private DumpstateFileAdapter adapter;
|
||||
|
||||
/**
|
||||
* Interface for handling dumpstate selection
|
||||
*/
|
||||
public interface OnDumpstateSelectedListener {
|
||||
void onDumpstateSelected(File dumpstateFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param dumpstateFiles List of dumpstate files
|
||||
* @param listener Listener for selection events
|
||||
*/
|
||||
public DumpstateSelectionDialog(@NonNull Context context,
|
||||
File[] dumpstateFiles,
|
||||
OnDumpstateSelectedListener listener) {
|
||||
super(context);
|
||||
this.dumpstateFiles = Arrays.asList(dumpstateFiles);
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.dialog_dumpstate_selection);
|
||||
|
||||
// Initialize views
|
||||
titleText = findViewById(R.id.titleText);
|
||||
recyclerView = findViewById(R.id.dumpstateRecyclerView);
|
||||
emptyView = findViewById(R.id.emptyView);
|
||||
selectButton = findViewById(R.id.selectButton);
|
||||
cancelButton = findViewById(R.id.cancelButton);
|
||||
|
||||
// Update title with file count
|
||||
titleText.setText(getContext().getString(R.string.dumpstate_selection_subtitle, dumpstateFiles.size()));
|
||||
|
||||
// Set up RecyclerView
|
||||
setupRecyclerView();
|
||||
|
||||
// Set up buttons
|
||||
cancelButton.setOnClickListener(v -> dismiss());
|
||||
selectButton.setOnClickListener(v -> {
|
||||
File selectedFile = adapter.getSelectedFile();
|
||||
if (selectedFile != null && listener != null) {
|
||||
listener.onDumpstateSelected(selectedFile);
|
||||
dismiss();
|
||||
}
|
||||
});
|
||||
|
||||
// Show empty view if no files
|
||||
if (dumpstateFiles.isEmpty()) {
|
||||
recyclerView.setVisibility(View.GONE);
|
||||
emptyView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
recyclerView.setVisibility(View.VISIBLE);
|
||||
emptyView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the RecyclerView with the adapter
|
||||
*/
|
||||
private void setupRecyclerView() {
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
adapter = new DumpstateFileAdapter(getContext(), dumpstateFiles);
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
// Listen for item selection to enable/disable select button
|
||||
adapter.setOnItemSelectedListener(file -> {
|
||||
selectButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.cardview.widget.CardView;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.adapter.DumpstateAdapter;
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.controller.FileController;
|
||||
import com.mattintech.simplelogupload.controller.ShareIntentHandler;
|
||||
import com.mattintech.simplelogupload.controller.UploadController;
|
||||
import com.mattintech.simplelogupload.service.FileUploadForegroundService;
|
||||
import com.mattintech.simplelogupload.util.PermissionUtil;
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main activity for the application
|
||||
*/
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
private static final String TAG = AppConstants.APP_TAG + "MainActivity";
|
||||
|
||||
private TextView dumpstateStatusText;
|
||||
private TextView selectedFileText;
|
||||
private Button selectFileButton;
|
||||
private Button uploadDumpstateButton;
|
||||
private Button uploadSelectedFileButton;
|
||||
private ProgressBar progressBar;
|
||||
private CardView selectedFileCard;
|
||||
|
||||
// Dumpstate multi-select components
|
||||
private RecyclerView dumpstateRecyclerView;
|
||||
private Button toggleDumpstateListButton;
|
||||
private Button selectAllButton;
|
||||
private Button clearSelectionsButton;
|
||||
private LinearLayout multiSelectButtonsLayout;
|
||||
private DumpstateAdapter dumpstateAdapter;
|
||||
|
||||
private File selectedFile;
|
||||
private File dumpstateFile;
|
||||
|
||||
private UploadController uploadController;
|
||||
|
||||
// BroadcastReceiver to listen for upload results
|
||||
private BroadcastReceiver uploadReceiver;
|
||||
|
||||
// Activity result launcher for file picking
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
|
||||
// Flag to track if dumpstate list is expanded
|
||||
private boolean isDumpstateListExpanded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Check if setup is required
|
||||
if (!PreferencesUtil.isSetupCompleted(this) || !PreferencesUtil.areServerSettingsConfigured(this)) {
|
||||
// Navigate to welcome activity
|
||||
Intent intent = new Intent(this, WelcomeActivity.class);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set content view
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
uploadController = new UploadController(this);
|
||||
|
||||
// Initialize UI elements
|
||||
dumpstateStatusText = findViewById(R.id.dumpstateStatusText);
|
||||
selectedFileText = findViewById(R.id.selectedFileText);
|
||||
selectFileButton = findViewById(R.id.selectFileButton);
|
||||
uploadDumpstateButton = findViewById(R.id.uploadDumpstateButton);
|
||||
uploadSelectedFileButton = findViewById(R.id.uploadSelectedFileButton);
|
||||
progressBar = findViewById(R.id.progressBar);
|
||||
selectedFileCard = findViewById(R.id.selectedFileCard);
|
||||
|
||||
// Initialize multi-select UI elements
|
||||
dumpstateRecyclerView = findViewById(R.id.dumpstateRecyclerView);
|
||||
toggleDumpstateListButton = findViewById(R.id.toggleDumpstateListButton);
|
||||
selectAllButton = findViewById(R.id.selectAllButton);
|
||||
clearSelectionsButton = findViewById(R.id.clearSelectionsButton);
|
||||
multiSelectButtonsLayout = findViewById(R.id.multiSelectButtonsLayout);
|
||||
|
||||
// Set up RecyclerView
|
||||
dumpstateRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
|
||||
// Set up button click listeners
|
||||
selectFileButton.setOnClickListener(v -> openFilePicker());
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadDumpstate());
|
||||
uploadSelectedFileButton.setOnClickListener(v -> uploadSelectedFile());
|
||||
toggleDumpstateListButton.setOnClickListener(v -> toggleDumpstateList());
|
||||
selectAllButton.setOnClickListener(v -> selectAllDumpstates());
|
||||
clearSelectionsButton.setOnClickListener(v -> clearDumpstateSelections());
|
||||
|
||||
// Initialize file picker launcher
|
||||
filePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> {
|
||||
if (uri != null) {
|
||||
handleFileSelection(uri);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for necessary permissions
|
||||
checkAndRequestPermissions();
|
||||
|
||||
// Initialize broadcast receiver
|
||||
setupBroadcastReceiver();
|
||||
|
||||
// Handle intent (e.g., from share)
|
||||
handleIntent(getIntent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the dumpstate list
|
||||
*/
|
||||
private void toggleDumpstateList() {
|
||||
isDumpstateListExpanded = !isDumpstateListExpanded;
|
||||
|
||||
if (isDumpstateListExpanded) {
|
||||
// Show the list
|
||||
loadDumpstateFiles();
|
||||
dumpstateRecyclerView.setVisibility(View.VISIBLE);
|
||||
multiSelectButtonsLayout.setVisibility(View.VISIBLE);
|
||||
toggleDumpstateListButton.setText(R.string.hide_dumpstates);
|
||||
|
||||
// Change the upload button to handle multiple files
|
||||
updateUploadButtonForMultiSelect();
|
||||
} else {
|
||||
// Hide the list
|
||||
dumpstateRecyclerView.setVisibility(View.GONE);
|
||||
multiSelectButtonsLayout.setVisibility(View.GONE);
|
||||
toggleDumpstateListButton.setText(R.string.show_dumpstates);
|
||||
|
||||
// Restore normal upload button
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadDumpstate());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load dumpstate files into the RecyclerView
|
||||
*/
|
||||
private void loadDumpstateFiles() {
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
List<File> fileList = new ArrayList<>(Arrays.asList(dumpstateFiles));
|
||||
|
||||
if (dumpstateAdapter == null) {
|
||||
dumpstateAdapter = new DumpstateAdapter(this, fileList);
|
||||
dumpstateAdapter.setOnSelectionChangedListener(this::onDumpstateSelectionChanged);
|
||||
dumpstateRecyclerView.setAdapter(dumpstateAdapter);
|
||||
} else {
|
||||
// Just update the adapter with new files
|
||||
dumpstateAdapter = new DumpstateAdapter(this, fileList);
|
||||
dumpstateAdapter.setOnSelectionChangedListener(this::onDumpstateSelectionChanged);
|
||||
dumpstateRecyclerView.setAdapter(dumpstateAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection changes in the dumpstate list
|
||||
*
|
||||
* @param selectedCount The number of selected items
|
||||
*/
|
||||
private void onDumpstateSelectionChanged(int selectedCount) {
|
||||
// Update the upload button text
|
||||
if (selectedCount > 0) {
|
||||
uploadDumpstateButton.setText(getString(R.string.upload_multiple_dumpstates, selectedCount));
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
} else {
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the upload button to handle multiple files
|
||||
*/
|
||||
private void updateUploadButtonForMultiSelect() {
|
||||
uploadDumpstateButton.setOnClickListener(v -> uploadSelectedDumpstates());
|
||||
|
||||
// Initially disable until files are selected
|
||||
uploadDumpstateButton.setEnabled(dumpstateAdapter != null && dumpstateAdapter.getSelectedCount() > 0);
|
||||
|
||||
// Update button text if there are already selections
|
||||
if (dumpstateAdapter != null && dumpstateAdapter.getSelectedCount() > 0) {
|
||||
uploadDumpstateButton.setText(getString(R.string.upload_multiple_dumpstates,
|
||||
dumpstateAdapter.getSelectedCount()));
|
||||
} else {
|
||||
uploadDumpstateButton.setText(R.string.upload_dumpstate_button);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all dumpstate files
|
||||
*/
|
||||
private void selectAllDumpstates() {
|
||||
if (dumpstateAdapter != null) {
|
||||
dumpstateAdapter.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dumpstate selections
|
||||
*/
|
||||
private void clearDumpstateSelections() {
|
||||
if (dumpstateAdapter != null) {
|
||||
dumpstateAdapter.clearSelections();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload selected dumpstate files
|
||||
*/
|
||||
private void uploadSelectedDumpstates() {
|
||||
if (dumpstateAdapter == null || dumpstateAdapter.getSelectedCount() == 0) {
|
||||
Toast.makeText(this, R.string.no_dumpstates_selected, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
List<File> selectedFiles = dumpstateAdapter.getSelectedFiles();
|
||||
int fileCount = selectedFiles.size();
|
||||
|
||||
// Show upload parameters dialog first
|
||||
new UploadParamsDialog(this, new UploadParamsDialog.UploadParamsListener() {
|
||||
@Override
|
||||
public void onUploadParamsSet(int maxDownloads, int expiryHours) {
|
||||
// Update UI
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
dumpstateStatusText.setText(getString(R.string.preparing_multiple_files, fileCount));
|
||||
|
||||
// Prepare and upload multiple files
|
||||
FileController.prepareMultipleFilesForUploadAsync(selectedFiles, MainActivity.this)
|
||||
.thenAccept(zipFile -> {
|
||||
Log.d(TAG, "Multiple files zipped successfully: " + zipFile.getAbsolutePath());
|
||||
|
||||
runOnUiThread(() -> {
|
||||
dumpstateStatusText.setText(getString(R.string.uploading_multiple_files, fileCount));
|
||||
});
|
||||
|
||||
// Upload the zip file with parameters
|
||||
startUploadService(zipFile, maxDownloads, expiryHours);
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
Log.e(TAG, "Error preparing multiple files", ex);
|
||||
runOnUiThread(() -> {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.file_error, ex.getMessage()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUploadParamsCancelled() {
|
||||
// User cancelled, do nothing
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleIntent(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the intent, particularly for share intents
|
||||
*
|
||||
* @param intent The intent to handle
|
||||
*/
|
||||
private void handleIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a share intent
|
||||
if (ShareIntentHandler.isShareIntent(intent)) {
|
||||
handleShareIntent(intent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a share intent
|
||||
*
|
||||
* @param intent The share intent
|
||||
*/
|
||||
private void handleShareIntent(Intent intent) {
|
||||
List<Uri> sharedUris = ShareIntentHandler.extractSharedUris(intent);
|
||||
|
||||
if (sharedUris.isEmpty()) {
|
||||
Toast.makeText(this, "No files shared", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, just handle the first URI
|
||||
Uri sharedUri = sharedUris.get(0);
|
||||
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
|
||||
// Process the shared URI
|
||||
ShareIntentHandler.processSharedUri(this, sharedUri)
|
||||
.thenAccept(file -> {
|
||||
// Save the file for upload
|
||||
updateSelectedFile(file);
|
||||
runOnUiThread(() -> {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
Log.e(TAG, "Error processing shared file", ex);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.file_error, ex.getMessage()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file picker
|
||||
*/
|
||||
private void openFilePicker() {
|
||||
filePickerLauncher.launch("*/*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection from file picker
|
||||
*
|
||||
* @param uri The URI of the selected file
|
||||
*/
|
||||
private void handleFileSelection(Uri uri) {
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
|
||||
// Process the URI
|
||||
ShareIntentHandler.processSharedUri(this, uri)
|
||||
.thenAccept(file -> {
|
||||
// Save the file for upload
|
||||
updateSelectedFile(file);
|
||||
runOnUiThread(() -> {
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
Log.e(TAG, "Error processing selected file", ex);
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.file_error, ex.getMessage()),
|
||||
Toast.LENGTH_LONG).show();
|
||||
progressBar.setVisibility(View.GONE);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the selected file
|
||||
*/
|
||||
private void uploadSelectedFile() {
|
||||
if (selectedFile == null || !selectedFile.exists()) {
|
||||
Toast.makeText(this, R.string.no_file_selected, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show upload parameters dialog
|
||||
showUploadParamsDialog(selectedFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the dumpstate file
|
||||
*/
|
||||
private void uploadDumpstate() {
|
||||
// Get all dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length == 0) {
|
||||
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file, use it directly
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
showUploadParamsDialog(dumpstateFile);
|
||||
} else {
|
||||
// Multiple dumpstate files are available, suggest expanding the list
|
||||
if (!isDumpstateListExpanded) {
|
||||
toggleDumpstateList();
|
||||
} else {
|
||||
// If list is already expanded, use the first file
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
showUploadParamsDialog(dumpstateFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start uploading the selected dumpstate file
|
||||
*
|
||||
* @param file The dumpstate file to upload
|
||||
* @param maxDownloads The maximum number of downloads
|
||||
* @param expiryHours The expiry time in hours
|
||||
*/
|
||||
private void startDumpstateUpload(File file, int maxDownloads, int expiryHours) {
|
||||
if (file == null || !file.exists()) {
|
||||
Toast.makeText(this, R.string.no_dumpstate_found, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
|
||||
|
||||
// Start the upload service with parameters
|
||||
startUploadService(file, maxDownloads, expiryHours);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the upload service with the given file and parameters
|
||||
*
|
||||
* @param file The file to upload
|
||||
* @param maxDownloads The maximum number of downloads
|
||||
* @param expiryHours The expiry time in hours
|
||||
*/
|
||||
private void startUploadService(File file, int maxDownloads, int expiryHours) {
|
||||
// Start the upload service
|
||||
Intent serviceIntent = new Intent(this, FileUploadForegroundService.class);
|
||||
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_FILE_PATH, file.getAbsolutePath());
|
||||
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_MAX_DOWNLOADS, maxDownloads);
|
||||
serviceIntent.putExtra(FileUploadForegroundService.EXTRA_EXPIRY_HOURS, expiryHours);
|
||||
startService(serviceIntent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the upload parameters dialog
|
||||
*
|
||||
* @param file The file to upload
|
||||
*/
|
||||
private void showUploadParamsDialog(File file) {
|
||||
new UploadParamsDialog(this, new UploadParamsDialog.UploadParamsListener() {
|
||||
@Override
|
||||
public void onUploadParamsSet(int maxDownloads, int expiryHours) {
|
||||
// Update UI
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
uploadSelectedFileButton.setEnabled(false);
|
||||
|
||||
if (file == selectedFile) {
|
||||
selectedFileText.setText(getString(R.string.uploading_file, file.getName()));
|
||||
} else if (file == dumpstateFile) {
|
||||
dumpstateStatusText.setText(getString(R.string.uploading_file, file.getName()));
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
}
|
||||
|
||||
// Start the upload service with parameters
|
||||
startUploadService(file, maxDownloads, expiryHours);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUploadParamsCancelled() {
|
||||
// User cancelled, do nothing
|
||||
}
|
||||
}).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected file UI
|
||||
*
|
||||
* @param file The selected file
|
||||
*/
|
||||
private void updateSelectedFile(File file) {
|
||||
selectedFile = file;
|
||||
runOnUiThread(() -> {
|
||||
selectedFileCard.setVisibility(View.VISIBLE);
|
||||
selectedFileText.setText(getString(R.string.selected_file, file.getName()));
|
||||
uploadSelectedFileButton.setVisibility(View.VISIBLE);
|
||||
uploadSelectedFileButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the broadcast receiver for upload results
|
||||
*/
|
||||
private void setupBroadcastReceiver() {
|
||||
uploadReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "Received broadcast: " + intent.getAction());
|
||||
progressBar.setVisibility(View.GONE);
|
||||
uploadSelectedFileButton.setEnabled(true);
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
|
||||
if (intent.getAction().equals(AppConstants.ACTION_UPLOAD_COMPLETE)) {
|
||||
String uploadCode = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE);
|
||||
Log.d(TAG, "Upload complete with code: " + uploadCode);
|
||||
showUploadSuccessDialog(uploadCode);
|
||||
} else if (intent.getAction().equals(AppConstants.ACTION_UPLOAD_ERROR)) {
|
||||
String errorMessage = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR);
|
||||
Log.e(TAG, "Upload error: " + errorMessage);
|
||||
Toast.makeText(MainActivity.this,
|
||||
getString(R.string.upload_error, errorMessage),
|
||||
Toast.LENGTH_LONG).show();
|
||||
resetUI();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI after upload completion or error
|
||||
*/
|
||||
private void resetUI() {
|
||||
// Get all dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.setText(getString(R.string.dumpstate_found, dumpstateFile.getName()));
|
||||
} else {
|
||||
// Multiple dumpstate files
|
||||
dumpstateStatusText.setText(getString(R.string.multiple_dumpstates_found) +
|
||||
" (" + dumpstateFiles.length + ")");
|
||||
|
||||
// If we have a specific file selected, show it
|
||||
if (dumpstateFile != null && dumpstateFile.exists()) {
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
} else {
|
||||
// Otherwise show the most recent one
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
}
|
||||
}
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
|
||||
// Update multiselect if active
|
||||
if (isDumpstateListExpanded && dumpstateAdapter != null) {
|
||||
loadDumpstateFiles();
|
||||
updateUploadButtonForMultiSelect();
|
||||
}
|
||||
} else {
|
||||
dumpstateFile = null;
|
||||
dumpstateStatusText.setText(R.string.no_dumpstate_found);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
}
|
||||
|
||||
if (selectedFile != null) {
|
||||
selectedFileText.setText(getString(R.string.selected_file, selectedFile.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
// Check for file if we have permissions
|
||||
if (PermissionUtil.hasAllFilesAccessPermission()) {
|
||||
checkForDumpStateAndUpdateUI();
|
||||
}
|
||||
|
||||
// Register the broadcast receiver
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(AppConstants.ACTION_UPLOAD_COMPLETE);
|
||||
filter.addAction(AppConstants.ACTION_UPLOAD_ERROR);
|
||||
|
||||
// Register with both regular and local broadcast managers to ensure delivery
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(uploadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(uploadReceiver, filter);
|
||||
}
|
||||
|
||||
// Also register with LocalBroadcastManager for reliability
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(uploadReceiver, filter);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
|
||||
// Unregister the broadcast receiver
|
||||
try {
|
||||
unregisterReceiver(uploadReceiver);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Receiver not registered, ignore
|
||||
}
|
||||
|
||||
// Also unregister from LocalBroadcastManager
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(uploadReceiver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and request necessary permissions
|
||||
*/
|
||||
private void checkAndRequestPermissions() {
|
||||
// Request notification permission
|
||||
PermissionUtil.requestNotificationPermission(this);
|
||||
|
||||
// Check and request all files access permission
|
||||
if (!PermissionUtil.hasAllFilesAccessPermission()) {
|
||||
PermissionUtil.requestAllFilesAccessPermission(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for dumpstate files and update the UI
|
||||
*/
|
||||
private void checkForDumpStateAndUpdateUI() {
|
||||
// Look for dumpstate files
|
||||
File[] dumpstateFiles = FileController.getAllDumpStateFiles();
|
||||
|
||||
if (dumpstateFiles.length > 0) {
|
||||
if (dumpstateFiles.length == 1) {
|
||||
// Only one dumpstate file found, use it directly
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
dumpstateStatusText.setText(getString(R.string.dumpstate_found, dumpstateFile.getName()));
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
} else {
|
||||
// Multiple dumpstate files found
|
||||
dumpstateStatusText.setText(getString(R.string.multiple_dumpstates_found) +
|
||||
" (" + dumpstateFiles.length + ")");
|
||||
|
||||
// Set the most recent one as default
|
||||
dumpstateFile = dumpstateFiles[0];
|
||||
// Show the dumpstate name in a new line
|
||||
dumpstateStatusText.append("\n" + dumpstateFile.getName());
|
||||
uploadDumpstateButton.setEnabled(true);
|
||||
|
||||
// Update toggle button visibility
|
||||
toggleDumpstateListButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
} else {
|
||||
dumpstateFile = null;
|
||||
dumpstateStatusText.setText(R.string.no_dumpstate_found);
|
||||
uploadDumpstateButton.setEnabled(false);
|
||||
toggleDumpstateListButton.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
|
||||
if (requestCode == PermissionUtil.REQUEST_MANAGE_EXTERNAL_STORAGE) {
|
||||
if (PermissionUtil.hasAllFilesAccessPermission()) {
|
||||
checkForDumpStateAndUpdateUI();
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_not_granted, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog with the upload success result
|
||||
*
|
||||
* @param uploadCode The upload code
|
||||
*/
|
||||
private void showUploadSuccessDialog(String uploadCode) {
|
||||
Log.d(TAG, "Opening upload success dialog with code: " + uploadCode);
|
||||
runOnUiThread(() -> {
|
||||
try {
|
||||
UploadResultDialog dialog = new UploadResultDialog(this, uploadCode);
|
||||
dialog.show();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error showing upload result dialog", e);
|
||||
Toast.makeText(this, getString(R.string.upload_success, uploadCode), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.main_menu, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
int id = item.getItemId();
|
||||
|
||||
if (id == R.id.action_history) {
|
||||
// Open upload history activity
|
||||
startActivity(new Intent(this, UploadHistoryActivity.class));
|
||||
return true;
|
||||
} else if (id == R.id.action_settings) {
|
||||
// Open settings activity
|
||||
startActivity(new Intent(this, SettingsActivity.class));
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
import com.mattintech.simplelogupload.model.UploadHistory;
|
||||
import com.mattintech.simplelogupload.view.adapter.UploadHistoryAdapter;
|
||||
import com.mattintech.simplelogupload.viewmodel.UploadHistoryViewModel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Activity for displaying upload history
|
||||
*/
|
||||
public class UploadHistoryActivity extends AppCompatActivity implements UploadHistoryAdapter.OnHistoryItemClickListener {
|
||||
private static final String TAG = AppConstants.APP_TAG + "HistoryActivity";
|
||||
|
||||
private UploadHistoryViewModel viewModel;
|
||||
private UploadHistoryAdapter adapter;
|
||||
private RecyclerView recyclerView;
|
||||
private TextView emptyView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_upload_history);
|
||||
|
||||
// Enable "up" navigation
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
// Initialize views
|
||||
recyclerView = findViewById(R.id.historyRecyclerView);
|
||||
emptyView = findViewById(R.id.emptyView);
|
||||
|
||||
// Set up the RecyclerView
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
adapter = new UploadHistoryAdapter(this);
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
// Initialize the ViewModel
|
||||
viewModel = new ViewModelProvider.AndroidViewModelFactory(getApplication())
|
||||
.create(UploadHistoryViewModel.class);
|
||||
|
||||
// Observe the history data
|
||||
viewModel.getAllHistory().observe(this, new Observer<List<UploadHistory>>() {
|
||||
@Override
|
||||
public void onChanged(List<UploadHistory> history) {
|
||||
// Update the cached copy of the history in the adapter
|
||||
adapter.setHistory(history);
|
||||
|
||||
// Show/hide empty view
|
||||
if (history == null || history.isEmpty()) {
|
||||
recyclerView.setVisibility(View.GONE);
|
||||
emptyView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
recyclerView.setVisibility(View.VISIBLE);
|
||||
emptyView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the delete status to provide feedback
|
||||
viewModel.getDeleteStatus().observe(this, deleteSuccess -> {
|
||||
if (deleteSuccess != null) {
|
||||
if (deleteSuccess) {
|
||||
Toast.makeText(this, "File deleted from server successfully", Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Toast.makeText(this, "Failed to delete file from server, but removed from local history",
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
int id = item.getItemId();
|
||||
|
||||
if (id == android.R.id.home) {
|
||||
finish();
|
||||
return true;
|
||||
} else if (id == R.id.action_clear_history) {
|
||||
// Show confirmation dialog
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Clear All History")
|
||||
.setMessage("Do you want to delete all uploads from both local history and the server?")
|
||||
.setPositiveButton("Yes", (dialog, which) -> {
|
||||
// Show progress dialog
|
||||
AlertDialog progressDialog = new AlertDialog.Builder(this)
|
||||
.setTitle("Deleting...")
|
||||
.setMessage("Attempting to delete all files from server and clear history.")
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
progressDialog.show();
|
||||
|
||||
// Attempt to delete all
|
||||
viewModel.deleteAll()
|
||||
.thenAccept(unused -> {
|
||||
runOnUiThread(() -> {
|
||||
progressDialog.dismiss();
|
||||
// Toast message will be shown through the observer
|
||||
});
|
||||
});
|
||||
})
|
||||
.setNegativeButton("No", null)
|
||||
.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyCodeClick(UploadHistory history) {
|
||||
copyToClipboard(history.getUploadCode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeleteClick(UploadHistory history) {
|
||||
// Show confirmation dialog
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Delete Upload")
|
||||
.setMessage("Do you want to delete this upload from both local history and the server?")
|
||||
.setPositiveButton("Yes", (dialog, which) -> {
|
||||
// Show progress dialog
|
||||
AlertDialog progressDialog = new AlertDialog.Builder(this)
|
||||
.setTitle("Deleting...")
|
||||
.setMessage("Attempting to delete from server and local history.")
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
progressDialog.show();
|
||||
|
||||
// Attempt to delete
|
||||
viewModel.delete(history)
|
||||
.thenAccept(unused -> {
|
||||
runOnUiThread(() -> {
|
||||
progressDialog.dismiss();
|
||||
});
|
||||
});
|
||||
})
|
||||
.setNegativeButton("No", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*
|
||||
* @param text The text to copy
|
||||
*/
|
||||
private void copyToClipboard(String text) {
|
||||
try {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("Upload Code", text);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
Toast.makeText(this, "Code copied to clipboard", Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error copying to clipboard", e);
|
||||
Toast.makeText(this, "Failed to copy code", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.constant.NetworkConstants;
|
||||
|
||||
/**
|
||||
* Dialog for setting upload parameters
|
||||
*/
|
||||
public class UploadParamsDialog extends Dialog {
|
||||
|
||||
private EditText maxDownloadsInput;
|
||||
private EditText expiryTimeInput;
|
||||
private final UploadParamsListener listener;
|
||||
|
||||
/**
|
||||
* Interface for upload parameter callbacks
|
||||
*/
|
||||
public interface UploadParamsListener {
|
||||
void onUploadParamsSet(int maxDownloads, int expiryHours);
|
||||
void onUploadParamsCancelled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param listener Callback listener
|
||||
*/
|
||||
public UploadParamsDialog(@NonNull Context context, UploadParamsListener listener) {
|
||||
super(context);
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.dialog_upload_params);
|
||||
|
||||
// Initialize views
|
||||
maxDownloadsInput = findViewById(R.id.maxDownloadsInput);
|
||||
expiryTimeInput = findViewById(R.id.expiryTimeInput);
|
||||
|
||||
// Set default values as hints
|
||||
maxDownloadsInput.setHint("Default: " + NetworkConstants.DEFAULT_MAX_DOWNLOADS);
|
||||
expiryTimeInput.setHint("Default: " + NetworkConstants.DEFAULT_EXPIRY_HOURS);
|
||||
|
||||
// Set up buttons
|
||||
Button confirmButton = findViewById(R.id.confirmButton);
|
||||
Button cancelButton = findViewById(R.id.cancelButton);
|
||||
|
||||
confirmButton.setOnClickListener(v -> {
|
||||
int maxDownloads = getMaxDownloadsValue();
|
||||
int expiryHours = getExpiryHoursValue();
|
||||
|
||||
// Validate expiry time
|
||||
if (expiryHours > NetworkConstants.MAX_EXPIRY_HOURS) {
|
||||
expiryTimeInput.setError("Maximum expiry time is " +
|
||||
NetworkConstants.MAX_EXPIRY_HOURS + " hours");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate max downloads (must be non-negative)
|
||||
if (maxDownloads < 0) {
|
||||
maxDownloadsInput.setError("Downloads must be 0 or greater");
|
||||
return;
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
listener.onUploadParamsSet(maxDownloads, expiryHours);
|
||||
}
|
||||
dismiss();
|
||||
});
|
||||
|
||||
cancelButton.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onUploadParamsCancelled();
|
||||
}
|
||||
dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the max downloads value from the input
|
||||
*
|
||||
* @return The max downloads value (default if not set)
|
||||
*/
|
||||
private int getMaxDownloadsValue() {
|
||||
if (TextUtils.isEmpty(maxDownloadsInput.getText())) {
|
||||
return NetworkConstants.DEFAULT_MAX_DOWNLOADS;
|
||||
}
|
||||
|
||||
try {
|
||||
return Integer.parseInt(maxDownloadsInput.getText().toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return NetworkConstants.DEFAULT_MAX_DOWNLOADS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiry hours value from the input
|
||||
*
|
||||
* @return The expiry hours value (default if not set)
|
||||
*/
|
||||
private int getExpiryHoursValue() {
|
||||
if (TextUtils.isEmpty(expiryTimeInput.getText())) {
|
||||
return NetworkConstants.DEFAULT_EXPIRY_HOURS;
|
||||
}
|
||||
|
||||
try {
|
||||
return Integer.parseInt(expiryTimeInput.getText().toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return NetworkConstants.DEFAULT_EXPIRY_HOURS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.constant.AppConstants;
|
||||
|
||||
/**
|
||||
* Dialog to display upload result
|
||||
*/
|
||||
public class UploadResultDialog extends Dialog {
|
||||
private static final String TAG = AppConstants.APP_TAG + "ResultDialog";
|
||||
|
||||
private final String uploadCode;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context The context
|
||||
* @param uploadCode The upload code
|
||||
*/
|
||||
public UploadResultDialog(Context context, String uploadCode) {
|
||||
super(context);
|
||||
this.uploadCode = uploadCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
setContentView(R.layout.upload_result_dialog);
|
||||
|
||||
TextView codeTextView = findViewById(R.id.uploadCode);
|
||||
codeTextView.setText(uploadCode);
|
||||
|
||||
Button copyButton = findViewById(R.id.copyButton);
|
||||
copyButton.setOnClickListener(v -> copyCodeToClipboard());
|
||||
|
||||
Button closeButton = findViewById(R.id.closeButton);
|
||||
closeButton.setOnClickListener(v -> dismiss());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the upload code to the clipboard
|
||||
*/
|
||||
private void copyCodeToClipboard() {
|
||||
try {
|
||||
ClipboardManager clipboard = (ClipboardManager)
|
||||
getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
|
||||
ClipData clip = ClipData.newPlainText("Upload Code", uploadCode);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
|
||||
Toast.makeText(getContext(), "Code copied to clipboard", Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error copying to clipboard", e);
|
||||
Toast.makeText(getContext(), "Failed to copy code", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
|
||||
/**
|
||||
* Welcome activity for first-time setup
|
||||
*/
|
||||
public class WelcomeActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_welcome);
|
||||
|
||||
// Hide action bar for cleaner look
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().hide();
|
||||
}
|
||||
|
||||
// Set up next button
|
||||
Button nextButton = findViewById(R.id.nextButton);
|
||||
nextButton.setOnClickListener(v -> {
|
||||
// Proceed to server setup activity
|
||||
Intent intent = new Intent(WelcomeActivity.this, ServerSetupActivity.class);
|
||||
startActivity(intent);
|
||||
// No finish() here to allow back navigation
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package com.mattintech.simplelogupload.view.adapter;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.mattintech.simplelogupload.R;
|
||||
import com.mattintech.simplelogupload.model.UploadHistory;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Adapter for displaying upload history items
|
||||
*/
|
||||
public class UploadHistoryAdapter extends RecyclerView.Adapter<UploadHistoryAdapter.HistoryViewHolder> {
|
||||
|
||||
private List<UploadHistory> history = new ArrayList<>();
|
||||
private final OnHistoryItemClickListener listener;
|
||||
private final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault());
|
||||
|
||||
/**
|
||||
* Interface for history item click events
|
||||
*/
|
||||
public interface OnHistoryItemClickListener {
|
||||
void onCopyCodeClick(UploadHistory history);
|
||||
void onDeleteClick(UploadHistory history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param listener The click listener
|
||||
*/
|
||||
public UploadHistoryAdapter(OnHistoryItemClickListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the history data
|
||||
*
|
||||
* @param history The new history data
|
||||
*/
|
||||
public void setHistory(List<UploadHistory> history) {
|
||||
this.history = history;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public HistoryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View itemView = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_upload_history, parent, false);
|
||||
return new HistoryViewHolder(itemView);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull HistoryViewHolder holder, int position) {
|
||||
UploadHistory current = history.get(position);
|
||||
|
||||
holder.fileNameTextView.setText(current.getFileName());
|
||||
holder.uploadCodeTextView.setText(current.getUploadCode());
|
||||
|
||||
// Format date
|
||||
String formattedDate = dateFormat.format(new Date(current.getUploadTime()));
|
||||
holder.uploadTimeTextView.setText(formattedDate);
|
||||
|
||||
// Set details text
|
||||
String details = String.format(Locale.getDefault(),
|
||||
"Max downloads: %d • Expires in %d hours",
|
||||
current.getMaxDownloads(),
|
||||
current.getExpiryHours());
|
||||
holder.detailsTextView.setText(details);
|
||||
|
||||
// Set click listeners
|
||||
holder.copyButton.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onCopyCodeClick(current);
|
||||
}
|
||||
});
|
||||
|
||||
holder.deleteButton.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onDeleteClick(current);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return history.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for history items
|
||||
*/
|
||||
static class HistoryViewHolder extends RecyclerView.ViewHolder {
|
||||
private final TextView fileNameTextView;
|
||||
private final TextView uploadCodeTextView;
|
||||
private final TextView uploadTimeTextView;
|
||||
private final TextView detailsTextView;
|
||||
private final Button copyButton;
|
||||
private final Button deleteButton;
|
||||
|
||||
HistoryViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
fileNameTextView = itemView.findViewById(R.id.fileNameText);
|
||||
uploadCodeTextView = itemView.findViewById(R.id.uploadCodeText);
|
||||
uploadTimeTextView = itemView.findViewById(R.id.uploadTimeText);
|
||||
detailsTextView = itemView.findViewById(R.id.detailsText);
|
||||
copyButton = itemView.findViewById(R.id.copyButton);
|
||||
deleteButton = itemView.findViewById(R.id.deleteButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
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,
|
||||
selectedFile = null,
|
||||
selectedFileName = null
|
||||
)
|
||||
}
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the selected file
|
||||
*/
|
||||
fun clearSelectedFile() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedFile = null,
|
||||
selectedFileName = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headerText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_header"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp"/>
|
||||
|
||||
<!-- Main Upload Options Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/uploadOptionsCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/headerText">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/upload_options_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectFileButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/select_file_button"
|
||||
android:drawableStart="@android:drawable/ic_menu_search"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/uploadDumpstateButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/upload_dumpstate_button"
|
||||
android:drawableStart="@android:drawable/ic_menu_save"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"/>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Dumpstate Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/dumpstateCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/uploadOptionsCard">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dumpstate_section_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dumpstateStatusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/looking_for_dumpstate"
|
||||
android:textSize="16sp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="start"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggleDumpstateListButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/show_dumpstates"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Small" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/dumpstateRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/multiSelectButtonsLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectAllButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/select_all"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/clearSelectionsButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/clear_selections"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Selected File Section -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/selectedFileCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/dumpstateCard">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/selected_file_section_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/selectedFileText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/no_file_selected"
|
||||
android:textSize="16sp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/uploadSelectedFileButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/upload_selected_file"
|
||||
android:visibility="gone"/>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@id/selectedFileCard"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="16dp"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,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>
|
||||
@@ -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>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".view.UploadHistoryActivity">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/historyRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
android:padding="8dp"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:text="No upload history"
|
||||
android:textSize="18sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/logoImage"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginTop="32dp"
|
||||
android:src="@drawable/ic_app_icon"
|
||||
android:contentDescription="SimpleLogUpload logo"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Welcome to SimpleLogUpload"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/logoImage" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeDescription"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Before you can use this app, you need to configure it to connect to your SimpleLogUpload server instance."
|
||||
android:textAlignment="center"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/welcomeTitle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/welcomeDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="The app requires:\n\n• Server Address - IP or hostname of your server\n• Server Port - Port your server is running on\n• Client Key - Authentication key for secure uploads\n\nYour administrator should have provided these details."
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/welcomeDescription" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/nextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="32dp"
|
||||
android:text="Continue to Setup"
|
||||
android:padding="12dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,53 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dumpstate_selection_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/dumpstateRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxHeight="300dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/no_dumpstate_found"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
android:padding="16dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@android:string/cancel"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/selectButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_button"
|
||||
android:enabled="false"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,97 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/upload_params_title"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/max_downloads_label"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/maxDownloadsInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:inputType="number"
|
||||
android:hint="@string/max_downloads_hint"
|
||||
android:importantForAutofill="no" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/max_downloads_note"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="italic"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/expiry_time_label"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/expiryTimeInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:inputType="number"
|
||||
android:hint="@string/expiry_time_hint"
|
||||
android:importantForAutofill="no" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/expiry_time_note"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="italic"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/cancel_button"
|
||||
style="@style/Widget.AppCompat.Button.Borderless" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/confirmButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/upload_button" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/checkBox"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileNameText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:ellipsize="start"
|
||||
android:singleLine="true"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileDateText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/fileNameText"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileSizeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintStart_toEndOf="@id/checkBox"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/fileDateText"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,90 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fileNameText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textStyle="bold"
|
||||
android:textSize="16sp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/uploadTimeText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#757575"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Code: "
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/uploadCodeText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textIsSelectable="true"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#0066CC" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detailsText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/copyButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Copy Code"
|
||||
style="@style/Widget.AppCompat.Button.Colored" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/deleteButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Delete"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -1,70 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dialogTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Upload Successful"
|
||||
android:textAlignment="center"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/codeLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Your upload code:"
|
||||
android:textAlignment="center"
|
||||
app:layout_constraintTop_toBottomOf="@id/dialogTitle"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/uploadCode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#007BFF"
|
||||
app:layout_constraintTop_toBottomOf="@id/codeLabel"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/copyButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="Copy Code"
|
||||
app:layout_constraintTop_toBottomOf="@id/uploadCode"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/closeButton"
|
||||
app:layout_constraintHorizontal_weight="1" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/closeButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Close"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored"
|
||||
app:layout_constraintTop_toBottomOf="@id/uploadCode"
|
||||
app:layout_constraintStart_toEndOf="@id/copyButton"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_weight="1" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x
|
||||
ext.kotlin_version = '1.9.22' // Updated for Compose Compiler 1.5.8 compatibility
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
Reference in New Issue
Block a user