From e600c89655d777458b91328118c0977d3baaa280 Mon Sep 17 00:00:00 2001 From: Signal Bean <157260218+signalbean@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:41:04 +0530 Subject: [PATCH 1/3] Refresh button on site (#57) * docs(contrib): update commit message examples with large file context * docs(readme): center align content and improve readability * feat(server): add support for large files - tested with files of 1, 1.5, 2, 2.5 GB - transfer times ranging 7-15 mins avg in ascending order - optimization needed for transfer speeds * feat: add backend API endpoints for refresh settings - Add RefreshSettings data class with enabled/intervalSeconds properties - Implement GET/POST /refresh-settings endpoints in KtorServer - Add refresh settings management to FileServerService - Support configurable auto-refresh intervals (5-300 seconds) * feat(ui): add manual and auto-refresh controls to footer - Replaced static footer text with interactive refresh section - Added manual refresh button with icon - Added auto-refresh status indicator - Added settings modal for enabling/disabling auto-refresh and setting interval - Updated HTML structure for better UX and configuration options * feat(auto-refresh): implement manual and auto-refresh logic with change detection - Added auto-refresh feature with interval, enable toggle, and countdown display - Implemented settings persistence via server and localStorage - Integrated change detection using file hash to avoid unnecessary reloads - Hooked up modal interactions for configuring refresh options - Replaced direct UI updates with forced refresh calls after file upload/delete * style(ui): add footer and refresh modal styling - Styled new footer layout with refresh controls and settings button - Added hover, active, and responsive styles for refresh controls - Styled settings modal form, inputs, and labels - Improved mobile layout with responsive adjustments * reverted changes in server file based on pr 56 feedback --- CONTRIBUTING.md | 6 +- README.md | 133 +++----- app/src/main/assets/index.html | 44 ++- app/src/main/assets/script.js | 314 +++++++++++++++++- app/src/main/assets/style.css | 134 ++++++++ .../transfer/server/FileServerService.kt | 17 +- .../com/matanh/transfer/server/KtorServer.kt | 210 ++++++++---- .../matanh/transfer/server/RefreshSettings.kt | 9 + 8 files changed, 706 insertions(+), 161 deletions(-) create mode 100644 app/src/main/java/com/matanh/transfer/server/RefreshSettings.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9661b4e..0e318c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,9 +53,9 @@ This setup live-reloads the site and forwards `/api/*` requests to the emulator. - for code changes, Use commit messages with tags, e.g.: - - `fix(server): handle large file uploads` - - `feat(main): add new button` - - `tests: add tests for upload endpoint` + - `fix(server): improve error handling for large file uploads` + - `feat(ui): add progress indicator for file transfers` + - `perf(server): optimize streaming for multi-GB files` - Fork the repo and create a branch for your change. - Open a pull request diff --git a/README.md b/README.md index 7100dd3..965fd76 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,74 @@ -# Transfer - -A simple local file server app for Android. Download and upload files quickly across devices over Wi-Fi — no cables, no cloud. - - - - - -
- - - - - - -
- -Transfer makes sharing files across your local network incredibly straightforward. Think of it as a temporary USB stick you can access from any computer or device with a web browser — but without the physical stick, powered by a clean and simple UI. - -Basically, a better alternative to running `uploadserver` through Termux. - -## The Problem It Solves - -Imagine you need to transfer a file between your laptop and desktop — but you just want to send the file and move on: - -* You don’t have a USB stick or cable handy -* You don’t want to configure SMB (enable/run Samba, then download a client) -* You don’t want to use the cloud — either because it’s slow, unprivate, or you simply don’t have easy access to it - -## Key Features - -* **Effortless LAN Sharing**: Once Transfer is active, it serves files from your chosen shared folder over HTTP. Any device on the same Wi-Fi can connect using a simple web address shown in the app. -* **Configurable Security**: - * **IP Permissions**: By default, new devices attempting to connect trigger an "Allow/Deny" popup on your phone, giving you control over who accesses your files. This can be turned off for trusted networks. - * **Password Protection**: For an added layer, you can secure access with a password (off by default). -* **Powerful CLI Access (curl-friendly)**: - * Transfer works great with command-line tools. Upload files directly using `curl -T yourfile.txt :8000`.Also,you can download files using`curl :8000/yourfile.txt`. -* **Dual Browse UI**: Manage and access your shared files directly within the Transfer app on your Android device, or through the intuitive web interface on any connected computer. -* **Quick In-App Transfers**: - * **Upload**: Easily select files from your phone’s storage to add them to the shared folder. - * **Paste**: Paste text from your phone’s clipboard directly into a new `.txt` file in the shared folder with a single tap. - -## How to get the app -#### Recommended ways: -1. from [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.matanh.transfer) (use an F-Droid client like Neo Store, Droid-ify, or add IzzyOnDroid repo.) - - - Get it on IzzyOnDroid +

Transfer

+ +

+ + Version + + + License + + Android + +

-2. [from Google play!](https://play.google.com/store/apps/details?id=com.matanh.transfer) - new +

A simple local file server for Android

-#### Other ways -Directly from [github releases](https://github.com/matan-h/Transfer/releases) (and you can also point Obtainium to this link.) +

+ Transfer files instantly across devices over Wi-Fi – no cables, no cloud, no hassle. +

-## Getting Started +## Screenshots -1. Install and open Transfer on your Android device. -2. Grant necessary permissions and select a folder you wish to share (suggestion: create a new folder called `Storage` in your home directory). -3. Tap "Start Server." -4. The app will display an IP address (e.g., `http://192.168.1.X:8000`). -5. Open this address in a web browser on any other device connected to the same Wi-Fi network. -6. You're in! If IP permissions are on (default), you'll get a prompt on your phone to allow the new device. +

+ + + + + + +

-It's designed to be that simple. Enjoy your new wireless drive. +## Features -## FAQ -### Why does my browser show an error? -If your browser displays errors like `ERR_SSL_PROTOCOL_ERROR`, `ERR_CONNECTION_CLOSED`, or `SSL_ERROR_RX_RECORD_TOO_LONG`, it's probably because you're trying to open the site using **HTTPS** instead of **HTTP**. To fix, change the URL from `https://...` to `http://....` +- **One-tap server**: Start HTTP server instantly +- **Cross-platform**: Access from any device with a web browser +- **Secure**: Device permissions and optional password protection +- **CLI-friendly**: Works with `curl` and command-line tools +- **No internet required**: Works over local Wi-Fi only +## Installation -### Can I set a specific IP address to use every time? -Yes, you can set a [static IP](https://junipersys.com/support/article/14695) on your Android device. +**Recommended:** +- [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.matanh.transfer) (F-Droid) +- [Google Play Store](https://play.google.com/store/apps/details?id=com.matanh.transfer) -### Can I use Transfer without an internet connection? -Yes, Transfer works over your local Wi-Fi network, so an internet connection is not required. However, all devices must be connected to the same local network. +**Alternative:** +- [GitHub Releases](https://github.com/matan-h/Transfer/releases) -### What happens to files I share/upload to the Transfer -Transfer copy the files to the shared folder you set earlier. +## Quick Start -### What happens if you click `T0` in the app version screen -It becomes `T1` :) +1. Install Transfer and select a folder to share +2. Tap "Start Server" +3. Open the displayed IP address in any browser on the same Wi-Fi +4. Allow access when prompted -## Planned changes +## Usage -- [ ] add an option to change the port in the settings -- [ ] fallback to hotspot IP in the display. -- [x] automatically update the IP when Wifi changes +1. Upload file(s) +2. Download file(s) + - Both in the app and the web interface. + +## FAQ + +**Browser shows SSL errors?** Use `http://` not `https://` +**Need consistent IP?** Set up a [static IP](https://junipersys.com/support/article/14695) on your device +**Files location?** Uploaded files go to your selected shared folder ## Contributing -Any contributions, bugs, or feedback are welcome! -Feel free to open an issue or PR. -To get started, look at the `CONTRIBUTING.md` file +Check [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Issues and PRs welcome! ## License -this repo is Licensed under the MIT license. + +MIT License – see [LICENSE](LICENSE) file. diff --git a/app/src/main/assets/index.html b/app/src/main/assets/index.html index 3775d07..227ce29 100644 --- a/app/src/main/assets/index.html +++ b/app/src/main/assets/index.html @@ -68,7 +68,25 @@

Available Files

-

Transfer App

+
@@ -86,6 +104,30 @@

Available Files

+ + + diff --git a/app/src/main/assets/script.js b/app/src/main/assets/script.js index f1aa687..5571373 100644 --- a/app/src/main/assets/script.js +++ b/app/src/main/assets/script.js @@ -229,16 +229,8 @@ document.addEventListener('DOMContentLoaded', () => { }); const result = await response.json(); if (response.ok) { - // Update the specific row - const deletedRow = filesTableBody.querySelector(`[data-file-name="${fileName}"]`); - if (deletedRow) { - deletedRow.remove(); - } - if (filesTableBody.children.length === 0) { - noFilesMessage.style.display = 'block'; - downloadAllZipButton.style.display = 'none'; - } - // Optionally show a temporary success message + // Force refresh after delete to ensure UI is updated and hash is reset + fetchFilesWithChangeDetection(true); console.log(`Successfully deleted: ${fileName}`); } else { // Using a custom message display instead of alert @@ -344,7 +336,8 @@ document.addEventListener('DOMContentLoaded', () => { progressBarFill.style.backgroundColor = 'var(--success-color)'; progressStatus.textContent = `Success: ${xhr.responseText}`; setTimeout(() => progressItem.remove(), 3000); // Remove success item after 3 seconds - fetchFiles(); // Refresh file list after successful upload + // Force refresh after upload to ensure UI is updated and hash is reset + fetchFilesWithChangeDetection(true); } else { progressBarFill.style.backgroundColor = 'var(--error-color)'; progressStatus.textContent = `Error: ${xhr.status} - ${xhr.responseText || 'Upload failed'}`; @@ -410,6 +403,299 @@ document.addEventListener('DOMContentLoaded', () => { } }); - // Initial load of files when the page is ready - fetchFiles(); -}); \ No newline at end of file + // --- Auto-Refresh Feature --- + const manualRefreshButton = document.getElementById('manual-refresh-button'); + const refreshTimerText = document.getElementById('refresh-timer-text'); + const autoRefreshStatus = document.getElementById('auto-refresh-status'); + const refreshSettingsButton = document.getElementById('refresh-settings-button'); + + // Settings modal elements + const refreshSettingsModalOverlay = document.getElementById('refresh-settings-modal-overlay'); + const refreshEnabledCheckbox = document.getElementById('refresh-enabled-checkbox'); + const refreshIntervalInput = document.getElementById('refresh-interval-input'); + const settingsSaveButton = document.getElementById('settings-save-button'); + const settingsCancelButton = document.getElementById('settings-cancel-button'); + + let autoRefreshInterval = null; + let refreshCountdown = null; + let lastFileListHash = null; + let refreshSettings = { + enabled: true, + intervalSeconds: 30 // Default 30 seconds + }; + + // Load refresh settings from server and localStorage + async function loadRefreshSettings() { + try { + // First try to get settings from server + const response = await fetch('/api/refresh-settings'); + if (response.ok) { + const serverSettings = await response.json(); + refreshSettings = { ...refreshSettings, ...serverSettings }; + // Save server settings to localStorage as backup + localStorage.setItem('autoRefreshSettings', JSON.stringify(refreshSettings)); + return; + } + } catch (e) { + console.warn('Failed to load settings from server, using local settings'); + } + + // Fallback to localStorage if server request fails + const saved = localStorage.getItem('autoRefreshSettings'); + if (saved) { + try { + refreshSettings = { ...refreshSettings, ...JSON.parse(saved) }; + } catch (e) { + console.warn('Failed to parse refresh settings, using defaults'); + } + } + } + + // Save refresh settings to server and localStorage + async function saveRefreshSettings() { + localStorage.setItem('autoRefreshSettings', JSON.stringify(refreshSettings)); + + try { + const response = await fetch('/api/refresh-settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(refreshSettings) + }); + + if (!response.ok) { + console.warn('Failed to save settings to server'); + } + } catch (e) { + console.warn('Failed to save settings to server:', e); + } + } + + // Generate a simple hash of the file list for change detection + function generateFileListHash(files) { + if (!files || files.length === 0) return 'empty'; + return files.map(f => `${f.name}-${f.size}-${f.lastModified}`).join('|'); + } + + // Check if files have changed since last check + function hasFilesChanged(files) { + const currentHash = generateFileListHash(files); + if (lastFileListHash === null) { + lastFileListHash = currentHash; + return false; // First time, no change to detect + } + const changed = lastFileListHash !== currentHash; + if (changed) { + lastFileListHash = currentHash; + } + return changed; + } + + // Enhanced fetchFiles with change detection + async function fetchFilesWithChangeDetection(forceRefresh = false) { + try { + const response = await fetch('/api/files'); + if (!response.ok) { + const errorText = await response.text(); + console.error('Error fetching files:', response.status, errorText); + filesTableBody.innerHTML = `Error loading files: ${errorText}`; + noFilesMessage.style.display = 'none'; + downloadAllZipButton.style.display = 'none'; + return false; + } + const data = await response.json(); + + // For initial load or forced refresh, always update + if (forceRefresh || lastFileListHash === null) { + renderFiles(data.files); + lastFileListHash = generateFileListHash(data.files); // Set initial hash + if (!forceRefresh && lastFileListHash !== null) { + console.log('Files updated automatically'); + } + return true; + } + + // For auto-refresh, only update if files actually changed + if (hasFilesChanged(data.files)) { + renderFiles(data.files); + console.log('Files updated automatically'); + return true; + } + + return false; + } catch (error) { + console.error('Failed to fetch files:', error); + filesTableBody.innerHTML = `Could not connect to server or error fetching files.`; + noFilesMessage.style.display = 'none'; + downloadAllZipButton.style.display = 'none'; + return false; + } + } + + // Update refresh timer display + function updateRefreshDisplay(secondsLeft = null) { + if (!refreshSettings.enabled) { + refreshTimerText.textContent = 'Auto-refresh: Off'; + autoRefreshStatus.classList.remove('active'); + return; + } + + autoRefreshStatus.classList.add('active'); + if (secondsLeft !== null) { + const minutes = Math.floor(secondsLeft / 60); + const seconds = secondsLeft % 60; + const timeStr = minutes > 0 ? `${minutes}:${seconds.toString().padStart(2, '0')}` : `${seconds}s`; + refreshTimerText.textContent = `Next refresh: ${timeStr}`; + } else { + refreshTimerText.textContent = `Auto-refresh: ${refreshSettings.intervalSeconds}s`; + } + } + + // Start auto-refresh + function startAutoRefresh() { + if (!refreshSettings.enabled) return; + + stopAutoRefresh(); // Clear any existing timers + + let secondsLeft = refreshSettings.intervalSeconds; + updateRefreshDisplay(secondsLeft); + + // Countdown timer + refreshCountdown = setInterval(() => { + secondsLeft--; + updateRefreshDisplay(secondsLeft); + + if (secondsLeft <= 0) { + console.log('Auto-refresh triggered'); + fetchFilesWithChangeDetection().then((updated) => { + if (updated) { + console.log('Auto-refresh: Files were updated'); + } else { + console.log('Auto-refresh: No changes detected'); + } + }); + secondsLeft = refreshSettings.intervalSeconds; + } + }, 1000); + } + + // Stop auto-refresh + function stopAutoRefresh() { + if (refreshCountdown) { + clearInterval(refreshCountdown); + refreshCountdown = null; + } + updateRefreshDisplay(); + } + + // Toggle auto-refresh on/off + async function toggleAutoRefresh() { + refreshSettings.enabled = !refreshSettings.enabled; + await saveRefreshSettings(); + + if (refreshSettings.enabled) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } + } + + // Manual refresh handler + manualRefreshButton.addEventListener('click', async () => { + manualRefreshButton.disabled = true; + manualRefreshButton.style.opacity = '0.6'; + + await fetchFilesWithChangeDetection(true); + + // Reset auto-refresh timer after manual refresh + if (refreshSettings.enabled) { + startAutoRefresh(); + } + + setTimeout(() => { + manualRefreshButton.disabled = false; + manualRefreshButton.style.opacity = '1'; + }, 1000); + }); + + // Settings modal functionality + function showSettingsModal() { + refreshEnabledCheckbox.checked = refreshSettings.enabled; + refreshIntervalInput.value = refreshSettings.intervalSeconds; + refreshSettingsModalOverlay.classList.add('active'); + } + + function hideSettingsModal() { + refreshSettingsModalOverlay.classList.remove('active'); + } + + async function saveSettingsFromModal() { + const newEnabled = refreshEnabledCheckbox.checked; + const newInterval = parseInt(refreshIntervalInput.value); + + if (newInterval < 5 || newInterval > 300) { + alert('Refresh interval must be between 5 and 300 seconds'); + return; + } + + refreshSettings.enabled = newEnabled; + refreshSettings.intervalSeconds = newInterval; + + await saveRefreshSettings(); + + if (refreshSettings.enabled) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } + + hideSettingsModal(); + } + + // Settings modal event listeners + refreshSettingsButton.addEventListener('click', showSettingsModal); + settingsSaveButton.addEventListener('click', saveSettingsFromModal); + settingsCancelButton.addEventListener('click', hideSettingsModal); + + // Close modal when clicking outside + refreshSettingsModalOverlay.addEventListener('click', (event) => { + if (event.target === refreshSettingsModalOverlay) { + hideSettingsModal(); + } + }); + + // Double-click on refresh status to toggle auto-refresh + autoRefreshStatus.addEventListener('dblclick', toggleAutoRefresh); + + // Add tooltip to refresh status + autoRefreshStatus.title = 'Double-click to toggle auto-refresh on/off'; + + // Override the original fetchFiles function + window.fetchFiles = fetchFilesWithChangeDetection; + + // Initialize refresh settings and start auto-refresh + loadRefreshSettings().then(() => { + updateRefreshDisplay(); + + // Initial load of files when the page is ready + fetchFilesWithChangeDetection(true).then(() => { + // Start auto-refresh after initial load + if (refreshSettings.enabled) { + startAutoRefresh(); + } + }); + }); + + // Pause auto-refresh when page is not visible (battery optimization) + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopAutoRefresh(); + } else if (refreshSettings.enabled) { + // Refresh immediately when page becomes visible, then restart timer + fetchFilesWithChangeDetection().then(() => { + startAutoRefresh(); + }); + } + }); +}); diff --git a/app/src/main/assets/style.css b/app/src/main/assets/style.css index 711ee3c..73d19c2 100644 --- a/app/src/main/assets/style.css +++ b/app/src/main/assets/style.css @@ -433,6 +433,140 @@ footer { box-shadow: 0 -2px 4px var(--shadow-light); } +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + flex-wrap: wrap; + gap: 1rem; +} + +.refresh-controls { + display: flex; + align-items: center; + gap: 1rem; +} + +.refresh-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + font-size: 0.9rem; + border-radius: 6px; + background-color: var(--button-background); + color: var(--button-text); + border: 1px solid var(--divider-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.refresh-button:hover { + background-color: var(--primary-color); + color: var(--white); + border-color: var(--primary-color); + transform: translateY(-1px); +} + +.refresh-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.auto-refresh-status { + font-size: 0.85rem; + color: var(--text-secondary); + padding: 0.4rem 0.8rem; + background-color: var(--app-background); + border-radius: 4px; + border: 1px solid var(--divider-color); + min-width: 120px; + text-align: center; +} + +.auto-refresh-status.active { + color: var(--primary-color); + border-color: var(--primary-color); + background-color: rgba(25, 118, 210, 0.05); +} + +.settings-button { + background: none; + border: 1px solid var(--divider-color); + border-radius: 4px; + padding: 0.4rem; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s ease; +} + +.settings-button:hover { + background-color: var(--button-background); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.settings-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.settings-form { + text-align: left; + margin: 1rem 0; +} + +.setting-item { + margin-bottom: 1rem; +} + +.setting-item label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--text-primary); +} + +.setting-item input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--primary-color); +} + +.setting-item input[type="number"] { + width: 80px; + padding: 0.4rem; + border: 1px solid var(--divider-color); + border-radius: 4px; + background-color: var(--card-background); + color: var(--text-primary); + font-size: 0.9rem; +} + +.setting-item small { + display: block; + color: var(--text-secondary); + font-size: 0.8rem; + margin-top: 0.2rem; +} + +@media (max-width: 768px) { + .footer-content { + flex-direction: column; + text-align: center; + } + + .refresh-controls { + flex-direction: column; + gap: 0.5rem; + } +} + /* Custom Modal for Confirmation */ .modal-overlay { display: none; diff --git a/app/src/main/java/com/matanh/transfer/server/FileServerService.kt b/app/src/main/java/com/matanh/transfer/server/FileServerService.kt index 3e68e47..feac491 100644 --- a/app/src/main/java/com/matanh/transfer/server/FileServerService.kt +++ b/app/src/main/java/com/matanh/transfer/server/FileServerService.kt @@ -254,11 +254,11 @@ class FileServerService : Service(), SharedPreferences.OnSharedPreferenceChangeL val serviceProvider = { this@FileServerService } ktorServer = - embeddedServer(CIO, port = Constants.SERVER_PORT, host = "0.0.0.0", module = { + embeddedServer(CIO, port = Constants.SERVER_PORT, host = "0.0.0.0") { ktorServer( applicationContext, serviceProvider, currentSharedFolderUri!! ) - }).apply { + }.apply { start(wait = false) } @@ -342,6 +342,19 @@ class FileServerService : Service(), SharedPreferences.OnSharedPreferenceChangeL fun checkPassword(providedPassword: String): Boolean = getServerPassword() == providedPassword + fun getRefreshSettings(): RefreshSettings { + val enabled = sharedPreferences.getBoolean("refresh_enabled", true) + val intervalSeconds = sharedPreferences.getInt("refresh_interval_seconds", 30) + return RefreshSettings(enabled, intervalSeconds) + } + + fun setRefreshSettings(settings: RefreshSettings) { + sharedPreferences.edit() + .putBoolean("refresh_enabled", settings.enabled) + .putInt("refresh_interval_seconds", settings.intervalSeconds) + .apply() + } + private fun createNotificationChannel() { val serviceChannel = NotificationChannel( Constants.NOTIFICATION_CHANNEL_ID, diff --git a/app/src/main/java/com/matanh/transfer/server/KtorServer.kt b/app/src/main/java/com/matanh/transfer/server/KtorServer.kt index 4bc8993..f523f3d 100644 --- a/app/src/main/java/com/matanh/transfer/server/KtorServer.kt +++ b/app/src/main/java/com/matanh/transfer/server/KtorServer.kt @@ -193,22 +193,27 @@ suspend fun handleFileUpload( logger.e("Failed to create document file for upload: $uniqueFileName") return null to "Failed to create file." } - // 5) Stream upload with a buffer + + // Stream upload with custom buffer for large files try { val byteReadChannel = byteReadChannelProvider() context.contentResolver.openOutputStream(newFileDoc.uri)?.use { outputStream -> - val channel = Channels.newChannel(outputStream) - byteReadChannel.copyTo(channel) + withContext(Dispatchers.IO) { + // Use optimized streaming for large files + val channel = Channels.newChannel(outputStream) + byteReadChannel.copyTo(channel) + outputStream.flush() + } } ?: throw Exception("Cannot open output stream for ${newFileDoc.uri}") logger.i("File '$uniqueFileName' uploaded successfully.") notifyService() return uniqueFileName to null } catch (e: Exception) { - newFileDoc.delete() // Clean up - logger.e("Error during file upload: $uniqueFileName") - return null to e.localizedMessage + newFileDoc.delete() // Clean up on failure + logger.e("Error during file upload: $uniqueFileName - ${e.message}") + return null to "Upload failed: ${e.localizedMessage}" } } @@ -329,6 +334,46 @@ fun Application.ktorServer( call.respondText("pong") } + get("/refresh-settings") { + try { + val settings = fileServerService.getRefreshSettings() + call.respond(settings) + } catch (e: Exception) { + logger.e("Error getting refresh settings: ${e.message}") + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error getting refresh settings: ${e.localizedMessage}") + ) + } + } + + post("/refresh-settings") { + try { + val requestBody = call.receiveText() + val jsonObject = JSONObject(requestBody) + val enabled = jsonObject.optBoolean("enabled", true) + val intervalSeconds = jsonObject.optInt("intervalSeconds", 30) + + if (intervalSeconds < 5 || intervalSeconds > 300) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Interval must be between 5 and 300 seconds") + ) + return@post + } + + val settings = RefreshSettings(enabled, intervalSeconds) + fileServerService.setRefreshSettings(settings) + call.respond(SuccessResponse("Refresh settings updated")) + } catch (e: Exception) { + logger.e("Error updating refresh settings: ${e.message}") + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error updating refresh settings: ${e.localizedMessage}") + ) + } + } + get("/files") { try { val filesList = baseDocumentFile.listFiles() @@ -437,55 +482,79 @@ fun Application.ktorServer( post("/upload") { var filesUploadedCount = 0 val uploadedFileNames = mutableListOf() + val failedUploads = mutableListOf() + try { - val multipart = call.receiveMultipart(formFieldLimit = Long.MAX_VALUE) // allow more then 50MB (#3) + // Configure multipart for large files with no size limit + val multipart = call.receiveMultipart() + multipart.forEachPart { part -> - when (part) { - is PartData.FileItem -> { - val originalFileName = part.originalFileName ?: "uploaded_file" - logger.d("Receiving file: $originalFileName") - val (fileName, error) = handleFileUpload( - context = applicationContext, - baseDocumentFile = baseDocumentFile, - originalFileName = originalFileName, - byteReadChannelProvider = { part.provider() }, - notifyService = { fileServerService.notifyFilePushed() } - ) - if (fileName != null) { - uploadedFileNames.add(fileName) - filesUploadedCount++ - } else { - logger.e("Upload failed for $originalFileName: $error") + try { + when (part) { + is PartData.FileItem -> { + val originalFileName = part.originalFileName ?: "uploaded_file" + logger.d("Receiving file: $originalFileName") + + val (fileName, error) = handleFileUpload( + context = applicationContext, + baseDocumentFile = baseDocumentFile, + originalFileName = originalFileName, + byteReadChannelProvider = { part.provider() }, + notifyService = { fileServerService.notifyFilePushed() } + ) + + if (fileName != null) { + uploadedFileNames.add(fileName) + filesUploadedCount++ + logger.i("Successfully uploaded file: $fileName") + } else { + failedUploads.add("$originalFileName: ${error ?: "Unknown error"}") + logger.e("Upload failed for $originalFileName: $error") + } } - } - is PartData.FormItem -> { - logger.d("Form item: ${part.name} = ${part.value}") - } + is PartData.FormItem -> { + logger.d("Form item: ${part.name} = ${part.value}") + } - else -> {} + else -> { + logger.d("Received unknown part type: ${part::class.simpleName}") + } + } + } catch (partException: Exception) { + logger.e("Error processing part: ${partException.message}") + failedUploads.add("Part processing error: ${partException.localizedMessage}") + } finally { + part.dispose() } - part.dispose() } - if (filesUploadedCount > 0) { - call.respondText( - "Successfully uploaded: ${ - uploadedFileNames.joinToString( - ", " - ) - }" - ) - } else { - call.respond( - HttpStatusCode.BadRequest, - "No files were uploaded or upload failed." - ) + + // Provide detailed response + when { + filesUploadedCount > 0 && failedUploads.isEmpty() -> { + call.respondText( + "Successfully uploaded ${filesUploadedCount} file(s): ${uploadedFileNames.joinToString(", ")}" + ) + } + filesUploadedCount > 0 && failedUploads.isNotEmpty() -> { + call.respondText( + "Partially successful: ${filesUploadedCount} uploaded (${uploadedFileNames.joinToString(", ")}), " + + "${failedUploads.size} failed (${failedUploads.joinToString("; ")})" + ) + } + else -> { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Upload failed: ${failedUploads.joinToString("; ")}") + ) + } } + } catch (e: Exception) { - logger.e("Exception during file upload") + logger.e("Exception during file upload: ${e.message}") call.respond( HttpStatusCode.InternalServerError, - "Upload error: ${e.localizedMessage}" + ErrorResponse("Upload error: ${e.localizedMessage}") ) } } @@ -534,28 +603,43 @@ fun Application.ktorServer( call.respond(HttpStatusCode.BadRequest, "Filename missing in path for PUT.") return@put } - // handle simple override - val targetFileDoc = baseDocumentFile.findFile(fileName) - if (targetFileDoc != null && targetFileDoc.exists()) { - targetFileDoc.delete() - } + + logger.d("Receiving file via PUT: $fileName") + + try { + val targetFileDoc = baseDocumentFile.findFile(fileName) + if (targetFileDoc != null && targetFileDoc.exists()) { + if (!targetFileDoc.delete()) { + logger.w("Failed to delete existing file for overwrite: $fileName") + } + } - val (uploadedFileName, error) = handleFileUpload( - context = applicationContext, - baseDocumentFile = baseDocumentFile, - originalFileName = fileName, - byteReadChannelProvider = { call.receiveChannel() }, - notifyService = { fileServerService.notifyFilePushed() } - ) - if (uploadedFileName != null) { - call.respond( - HttpStatusCode.Created, - "File '$uploadedFileName' uploaded via PUT." + val (uploadedFileName, error) = handleFileUpload( + context = applicationContext, + baseDocumentFile = baseDocumentFile, + originalFileName = fileName, + byteReadChannelProvider = { call.receiveChannel() }, + notifyService = { fileServerService.notifyFilePushed() } ) - } else { + + if (uploadedFileName != null) { + logger.i("File uploaded successfully via PUT: $uploadedFileName") + call.respond( + HttpStatusCode.Created, + "File '$uploadedFileName' uploaded successfully via PUT." + ) + } else { + logger.e("PUT upload failed for $fileName: $error") + call.respond( + HttpStatusCode.InternalServerError, + "PUT upload failed: $error" + ) + } + } catch (e: Exception) { + logger.e("Exception during PUT upload for $fileName: ${e.message}") call.respond( HttpStatusCode.InternalServerError, - "Error during PUT upload: $error" + "PUT upload error: ${e.localizedMessage}" ) } } @@ -609,4 +693,4 @@ data class FileListResponse(val files: List) data class ErrorResponse(val error: String) @Serializable -data class SuccessResponse(val message: String) \ No newline at end of file +data class SuccessResponse(val message: String) diff --git a/app/src/main/java/com/matanh/transfer/server/RefreshSettings.kt b/app/src/main/java/com/matanh/transfer/server/RefreshSettings.kt new file mode 100644 index 0000000..28b8c26 --- /dev/null +++ b/app/src/main/java/com/matanh/transfer/server/RefreshSettings.kt @@ -0,0 +1,9 @@ +package com.matanh.transfer.server + +import kotlinx.serialization.Serializable + +@Serializable +data class RefreshSettings( + val enabled: Boolean = true, + val intervalSeconds: Int = 30 +) From 284bca7f969161ec37c366e0f5edee758e4fe95d Mon Sep 17 00:00:00 2001 From: rampage445 Date: Thu, 8 Jan 2026 03:06:19 +0600 Subject: [PATCH 2/3] feat(ui): add auto start server preference --- .../java/com/matanh/transfer/MainActivity.kt | 16 +++++++++++++--- .../com/matanh/transfer/ui/SettingsActivity.kt | 9 +++++++++ app/src/main/res/values/strings.xml | 3 +++ app/src/main/res/xml/preferences.xml | 6 ++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/matanh/transfer/MainActivity.kt b/app/src/main/java/com/matanh/transfer/MainActivity.kt index 1a6e1df..a8e5a48 100644 --- a/app/src/main/java/com/matanh/transfer/MainActivity.kt +++ b/app/src/main/java/com/matanh/transfer/MainActivity.kt @@ -4,11 +4,13 @@ import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager import android.content.ComponentName +import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.net.Uri import android.os.Bundle import android.os.IBinder +import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View @@ -148,9 +150,17 @@ class MainActivity : AppCompatActivity() { currentSelectedFolderUri = uri if (uri != null) { // If URI is valid, ensure the server is started with it - startFileServer(uri) - lifecycleScope.launch { - shareHandler.handleIntent(intent, currentSelectedFolderUri)} + val sharedPrefs = getSharedPreferences(Constants.SHARED_PREFS_NAME, Context.MODE_PRIVATE) + val autoStartEnabled = sharedPrefs.getBoolean( + getString(R.string.pref_key_auto_start_server_disabled), + true + ) + if(autoStartEnabled) { + startFileServer(uri) + lifecycleScope.launch { + shareHandler.handleIntent(intent, currentSelectedFolderUri) + } + } } else { // If no URI is set, guide the user to settings navigateToSettingsWithMessage(getString(R.string.select_shared_folder_prompt)) diff --git a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt b/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt index 036e5cb..3968348 100644 --- a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt +++ b/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt @@ -88,6 +88,15 @@ class SettingsActivity : AppCompatActivity() { true } + // SwitchPreferenceCompat + val autoStartSwitch = + findPreference(getString(R.string.pref_key_auto_start_server_disabled)) + + autoStartSwitch?.setOnPreferenceChangeListener { preference, newValue -> + val isEnabled = newValue as Boolean + true + } + // Folder Selection Preference val folderPreference = findPreference("pref_key_shared_folder") folderPreference?.setOnPreferenceClickListener { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a5bc10..8557b14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,6 +47,9 @@ Allow Deny + auto_start_disabled + Auto Start Server + Auto‑start the server on app launch Security Display Permission Dialog Ask for permission for new IPs. Valid for 1 hour. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 4fbb4dd..f3a005d 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -21,6 +21,12 @@ app:key="pref_key_shared_folder" app:title="Shared Folder" app:summary="Select the folder to share" /> + + \ No newline at end of file From 49e65e96f52dd1a948749f32c11a6621b2c6ef71 Mon Sep 17 00:00:00 2001 From: rampage445 Date: Thu, 8 Jan 2026 03:13:35 +0600 Subject: [PATCH 3/3] feat(ui): added auto start server preference --- app/src/main/java/com/matanh/transfer/MainActivity.kt | 2 +- app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt | 2 +- app/src/main/res/values/strings.xml | 1 - app/src/main/res/xml/preferences.xml | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/matanh/transfer/MainActivity.kt b/app/src/main/java/com/matanh/transfer/MainActivity.kt index a8e5a48..84309f8 100644 --- a/app/src/main/java/com/matanh/transfer/MainActivity.kt +++ b/app/src/main/java/com/matanh/transfer/MainActivity.kt @@ -708,4 +708,4 @@ class MainActivity : AppCompatActivity() { // If action mode is active, finish it actionMode?.finish() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt b/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt index 3968348..e00de68 100644 --- a/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt +++ b/app/src/main/java/com/matanh/transfer/ui/SettingsActivity.kt @@ -88,7 +88,7 @@ class SettingsActivity : AppCompatActivity() { true } - // SwitchPreferenceCompat + // SwitchPreferenceCompat val autoStartSwitch = findPreference(getString(R.string.pref_key_auto_start_server_disabled)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8557b14..e8aedd4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,5 +138,4 @@ Failed to generate QR code No IP address available - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index f3a005d..ca595ad 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -28,5 +28,4 @@ app:summary="@string/pref_key_auto_start_server_summary" app:defaultValue="true" /> - \ No newline at end of file