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))
|
languageVersion.set(JavaLanguageVersion.of(17))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = '1.5.8'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -77,6 +85,23 @@ dependencies {
|
|||||||
// Coroutines
|
// Coroutines
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||||
|
|
||||||
|
// Jetpack Compose
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
|
||||||
|
implementation composeBom
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'androidx.compose.material3:material3'
|
||||||
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.9.3'
|
||||||
|
implementation 'androidx.navigation:navigation-compose:2.8.5'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
||||||
|
implementation 'androidx.compose.runtime:runtime-livedata'
|
||||||
|
|
||||||
|
// Compose Debug
|
||||||
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.mattintech.simplelogupload">
|
package="com.mattintech.simplelogupload">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_app_icon"
|
android:icon="@drawable/ic_app_icon"
|
||||||
|
android:roundIcon="@drawable/ic_app_icon"
|
||||||
android:label="SimpleLogUpload"
|
android:label="SimpleLogUpload"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
@@ -22,10 +24,13 @@
|
|||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
||||||
|
|
||||||
<!-- Main Activity -->
|
<!-- NEW: Compose Main Activity (LAUNCHER) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".view.MainActivity"
|
android:name=".ComposeMainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||||
|
|
||||||
|
<!-- LAUNCHER - App starts here -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -46,44 +51,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Upload History Activity -->
|
<!-- QR Code Scanner Activity -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".view.UploadHistoryActivity"
|
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||||
android:label="Upload History"
|
android:screenOrientation="portrait"
|
||||||
android:parentActivityName=".view.MainActivity"
|
android:stateNotNeeded="true"
|
||||||
android:exported="false">
|
android:theme="@style/zxing_CaptureTheme"
|
||||||
<meta-data
|
android:windowSoftInputMode="stateAlwaysHidden"
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
tools:replace="screenOrientation" />
|
||||||
android:value=".view.MainActivity" />
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- Settings Activity -->
|
|
||||||
<activity
|
|
||||||
android:name=".view.SettingsActivity"
|
|
||||||
android:label="Settings"
|
|
||||||
android:parentActivityName=".view.MainActivity"
|
|
||||||
android:exported="false">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
|
||||||
android:value=".view.MainActivity" />
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- Welcome Activity -->
|
|
||||||
<activity
|
|
||||||
android:name=".view.WelcomeActivity"
|
|
||||||
android:label="Welcome"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
|
|
||||||
|
|
||||||
<!-- Server Setup Activity -->
|
|
||||||
<activity
|
|
||||||
android:name=".view.ServerSetupActivity"
|
|
||||||
android:label="Server Setup"
|
|
||||||
android:exported="false">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.PARENT_ACTIVITY"
|
|
||||||
android:value=".view.WelcomeActivity" />
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<!-- File Upload Foreground Service -->
|
<!-- File Upload Foreground Service -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -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 class AppConstants {
|
||||||
public static final String APP_TAG = "SLU::";
|
public static final String APP_TAG = "SLU::";
|
||||||
|
|
||||||
// Broadcast actions
|
// Broadcast actions (service -> UI)
|
||||||
public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
|
public static final String ACTION_UPLOAD_COMPLETE = "com.mattintech.simplelogupload.UPLOAD_COMPLETE";
|
||||||
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR";
|
public static final String ACTION_UPLOAD_ERROR = "com.mattintech.simplelogupload.UPLOAD_ERROR";
|
||||||
|
public static final String ACTION_UPLOAD_PROGRESS = "com.mattintech.simplelogupload.UPLOAD_PROGRESS";
|
||||||
|
public static final String ACTION_ZIP_PROGRESS = "com.mattintech.simplelogupload.ZIP_PROGRESS";
|
||||||
|
public static final String ACTION_UPLOAD_PAUSED = "com.mattintech.simplelogupload.UPLOAD_PAUSED";
|
||||||
|
public static final String ACTION_UPLOAD_RESUMED = "com.mattintech.simplelogupload.UPLOAD_RESUMED";
|
||||||
|
public static final String ACTION_UPLOAD_CANCELLED = "com.mattintech.simplelogupload.UPLOAD_CANCELLED";
|
||||||
|
|
||||||
|
// Intent actions (UI/notification -> service)
|
||||||
|
public static final String ACTION_PAUSE_UPLOAD = "com.mattintech.simplelogupload.PAUSE_UPLOAD";
|
||||||
|
public static final String ACTION_RESUME_UPLOAD = "com.mattintech.simplelogupload.RESUME_UPLOAD";
|
||||||
|
public static final String ACTION_CANCEL_UPLOAD = "com.mattintech.simplelogupload.CANCEL_UPLOAD";
|
||||||
|
|
||||||
// Intent extras
|
// Intent extras
|
||||||
public static final String EXTRA_UPLOAD_CODE = "upload_code";
|
public static final String EXTRA_UPLOAD_CODE = "upload_code";
|
||||||
public static final String EXTRA_UPLOAD_ERROR = "upload_error";
|
public static final String EXTRA_UPLOAD_ERROR = "upload_error";
|
||||||
|
public static final String EXTRA_BYTES_UPLOADED = "bytes_uploaded";
|
||||||
|
public static final String EXTRA_TOTAL_BYTES = "total_bytes";
|
||||||
|
public static final String EXTRA_PERCENT_COMPLETE = "percent_complete";
|
||||||
|
|
||||||
// File patterns
|
// File patterns
|
||||||
public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip";
|
public static final String DUMPSTATE_FILE_PATTERN = "dumpState_.*\\.zip";
|
||||||
|
|||||||
@@ -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 {
|
public class UploadStrategySelector {
|
||||||
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
|
private static final String TAG = AppConstants.APP_TAG + "UploadStrategy";
|
||||||
|
|
||||||
// Threshold for using chunked uploads
|
// Threshold for using chunked uploads - matches chunk sizes
|
||||||
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 50 * 1024 * 1024; // 50MB for WiFi
|
// Only use chunked upload if file is larger than chunk size
|
||||||
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 10 * 1024 * 1024; // 10MB for mobile
|
private static final long CHUNKED_UPLOAD_THRESHOLD_WIFI = 10 * 1024 * 1024; // 10MB for WiFi (matches chunk size)
|
||||||
|
private static final long CHUNKED_UPLOAD_THRESHOLD_MOBILE = 4 * 1024 * 1024; // 4MB for mobile (matches chunk size)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if chunked uploads should be used for the given file
|
* Check if chunked uploads should be used for the given file
|
||||||
|
|||||||
@@ -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_SERVER_PORT = "server_port";
|
||||||
public static final String KEY_CLIENT_KEY = "client_key";
|
public static final String KEY_CLIENT_KEY = "client_key";
|
||||||
public static final String KEY_SETUP_COMPLETED = "setup_completed";
|
public static final String KEY_SETUP_COMPLETED = "setup_completed";
|
||||||
|
public static final String KEY_ONBOARDING_COMPLETED = "onboarding_completed";
|
||||||
|
public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads";
|
||||||
|
public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours";
|
||||||
|
public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload";
|
||||||
|
public static final String KEY_UPLOAD_IN_PROGRESS = "upload_in_progress";
|
||||||
|
public static final String KEY_UPLOAD_FILE_NAME = "upload_file_name";
|
||||||
|
public static final String KEY_ENABLE_PARALLEL_UPLOADS = "enable_parallel_uploads";
|
||||||
|
|
||||||
// Default values
|
// Default values
|
||||||
private static final String DEFAULT_SERVER_ADDRESS = "";
|
private static final String DEFAULT_SERVER_ADDRESS = "";
|
||||||
private static final int DEFAULT_SERVER_PORT = 0;
|
private static final int DEFAULT_SERVER_PORT = 0;
|
||||||
private static final String DEFAULT_CLIENT_KEY = "";
|
private static final String DEFAULT_CLIENT_KEY = "";
|
||||||
private static final boolean DEFAULT_SETUP_COMPLETED = false;
|
private static final boolean DEFAULT_SETUP_COMPLETED = false;
|
||||||
|
private static final boolean DEFAULT_ONBOARDING_COMPLETED = false;
|
||||||
|
private static final int DEFAULT_MAX_DOWNLOADS = 5;
|
||||||
|
private static final int DEFAULT_EXPIRY_HOURS = 24;
|
||||||
|
private static final boolean DEFAULT_ASK_BEFORE_UPLOAD = true;
|
||||||
|
private static final boolean DEFAULT_ENABLE_PARALLEL_UPLOADS = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the shared preferences instance
|
* Get the shared preferences instance
|
||||||
@@ -126,6 +138,11 @@ public class PreferencesUtil {
|
|||||||
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
|
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
|
||||||
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
|
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
|
||||||
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED);
|
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED);
|
||||||
|
editor.putBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||||
|
editor.putInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||||
|
editor.putInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||||
|
editor.putBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||||
|
editor.putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||||
editor.apply();
|
editor.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +166,86 @@ public class PreferencesUtil {
|
|||||||
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply();
|
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if onboarding has been completed
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return True if onboarding has been completed, false otherwise
|
||||||
|
*/
|
||||||
|
public static boolean isOnboardingCompleted(Context context) {
|
||||||
|
return getPreferences(context).getBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the onboarding completed status
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param completed True if onboarding has been completed, false otherwise
|
||||||
|
*/
|
||||||
|
public static void setOnboardingCompleted(Context context, boolean completed) {
|
||||||
|
getPreferences(context).edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default max downloads from preferences
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return The default max downloads
|
||||||
|
*/
|
||||||
|
public static int getDefaultMaxDownloads(Context context) {
|
||||||
|
return getPreferences(context).getInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default max downloads in preferences
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param maxDownloads The default max downloads to save
|
||||||
|
*/
|
||||||
|
public static void setDefaultMaxDownloads(Context context, int maxDownloads) {
|
||||||
|
getPreferences(context).edit().putInt(KEY_DEFAULT_MAX_DOWNLOADS, maxDownloads).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default expiry hours from preferences
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return The default expiry hours
|
||||||
|
*/
|
||||||
|
public static int getDefaultExpiryHours(Context context) {
|
||||||
|
return getPreferences(context).getInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default expiry hours in preferences
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param expiryHours The default expiry hours to save
|
||||||
|
*/
|
||||||
|
public static void setDefaultExpiryHours(Context context, int expiryHours) {
|
||||||
|
getPreferences(context).edit().putInt(KEY_DEFAULT_EXPIRY_HOURS, expiryHours).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ask before upload preference
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return True if should ask before upload, false otherwise
|
||||||
|
*/
|
||||||
|
public static boolean getAskBeforeUpload(Context context) {
|
||||||
|
return getPreferences(context).getBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the ask before upload preference
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param askBeforeUpload True if should ask before upload, false otherwise
|
||||||
|
*/
|
||||||
|
public static void setAskBeforeUpload(Context context, boolean askBeforeUpload) {
|
||||||
|
getPreferences(context).edit().putBoolean(KEY_ASK_BEFORE_UPLOAD, askBeforeUpload).apply();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if all required server settings are configured
|
* Check if all required server settings are configured
|
||||||
*
|
*
|
||||||
@@ -163,4 +260,62 @@ public class PreferencesUtil {
|
|||||||
|
|
||||||
return !address.isEmpty() && port > 0 && !clientKey.isEmpty();
|
return !address.isEmpty() && port > 0 && !clientKey.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an upload is currently in progress
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return True if an upload is in progress, false otherwise
|
||||||
|
*/
|
||||||
|
public static boolean isUploadInProgress(Context context) {
|
||||||
|
return getPreferences(context).getBoolean(KEY_UPLOAD_IN_PROGRESS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the upload in progress status
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param inProgress True if upload is in progress, false otherwise
|
||||||
|
* @param fileName The name of the file being uploaded (optional)
|
||||||
|
*/
|
||||||
|
public static void setUploadInProgress(Context context, boolean inProgress, String fileName) {
|
||||||
|
SharedPreferences.Editor editor = getPreferences(context).edit();
|
||||||
|
editor.putBoolean(KEY_UPLOAD_IN_PROGRESS, inProgress);
|
||||||
|
if (inProgress && fileName != null) {
|
||||||
|
editor.putString(KEY_UPLOAD_FILE_NAME, fileName);
|
||||||
|
} else if (!inProgress) {
|
||||||
|
editor.remove(KEY_UPLOAD_FILE_NAME);
|
||||||
|
}
|
||||||
|
editor.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name of the file currently being uploaded
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return The file name, or null if no upload is in progress
|
||||||
|
*/
|
||||||
|
public static String getUploadFileName(Context context) {
|
||||||
|
return getPreferences(context).getString(KEY_UPLOAD_FILE_NAME, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parallel uploads enabled preference
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @return True if parallel uploads are enabled, false otherwise
|
||||||
|
*/
|
||||||
|
public static boolean isParallelUploadsEnabled(Context context) {
|
||||||
|
return getPreferences(context).getBoolean(KEY_ENABLE_PARALLEL_UPLOADS, DEFAULT_ENABLE_PARALLEL_UPLOADS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the parallel uploads enabled preference
|
||||||
|
*
|
||||||
|
* @param context The application context
|
||||||
|
* @param enabled True to enable parallel uploads, false otherwise
|
||||||
|
*/
|
||||||
|
public static void setParallelUploadsEnabled(Context context, boolean enabled) {
|
||||||
|
getPreferences(context).edit().putBoolean(KEY_ENABLE_PARALLEL_UPLOADS, enabled).apply();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x
|
ext.kotlin_version = '1.9.22' // Updated for Compose Compiler 1.5.8 compatibility
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
Reference in New Issue
Block a user