From fdefae7bb1549ac9dedfd28943a8b5fa88eb11f1 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 25 Feb 2026 21:30:32 +0800 Subject: [PATCH 1/4] Implement support for big files --- app/build.gradle | 4 + .../texteditor/read/FileWindowReader.kt | 244 ++++++++++ .../texteditor/read/ReadTextFileCallable.java | 38 +- .../texteditor/read/ReadTextFileTask.kt | 13 + .../texteditor/ReturnedValueOnReadFile.kt | 5 + .../texteditor/TextEditorActivity.java | 116 ++++- .../texteditor/TextEditorActivityViewModel.kt | 85 ++++ .../texteditor/read/FileWindowReaderTest.kt | 427 ++++++++++++++++++ .../read/ReadTextFileCallableTest.kt | 102 +++++ .../services/ftp/FtpReceiverTest.kt | 2 +- .../texteditor/ReturnedValueOnReadFileTest.kt | 81 ++++ .../TextEditorActivityViewModelTest.kt | 342 ++++++++++++++ gradle/libs.versions.toml | 5 + 13 files changed, 1460 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt create mode 100644 app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFileTest.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index 3c380ce487..4ab82d6613 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -145,6 +145,10 @@ dependencies { implementation libs.androidX.constraintLayout implementation libs.androidX.multidex //Multiple dex files implementation libs.androidX.biometric + implementation libs.androidX.lifecycle.viewmodel.ktx + implementation libs.androidX.lifecycle.livedata.ktx + implementation libs.kotlinx.coroutines.core + implementation libs.kotlinx.coroutines.android implementation libs.room.runtime implementation libs.room.rxjava2 diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt new file mode 100644 index 0000000000..6c15835f7d --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.read + +import android.content.ContentResolver +import android.net.Uri +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.channels.FileChannel +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction + +/** + * Seekable file reader that supports reading a window of characters from a file, + * snapping to UTF-8 character boundaries and line boundaries. + * + * All I/O methods are regular (non-suspend) functions — the caller is responsible + * for dispatching to Dispatchers.IO. + */ +class FileWindowReader( + private val channel: FileChannel, + private val charset: Charset = Charsets.UTF_8, + val fileSize: Long, + private val closeable: Closeable? = null, +) : Closeable { + data class WindowResult( + val text: String, + /** Actual start byte offset (snapped to char/line boundary). */ + val startByte: Long, + /** Byte offset after the last byte read. */ + val endByte: Long, + val isStartOfFile: Boolean, + val isEndOfFile: Boolean, + ) + + /** + * Read a window of text starting at approximately [byteOffset]. + * + * The method: + * 1. Adjusts the offset to land on a UTF-8 character boundary + * 2. Reads enough bytes to decode up to [maxChars] characters + * 3. Snaps the start to the first newline (unless at file start) + * 4. Snaps the end to the last newline (unless at file end) + * 5. Returns the decoded string and actual byte range consumed + */ + fun readWindow( + byteOffset: Long, + maxChars: Int, + ): WindowResult { + val safeOffset = snapToCharBoundary(byteOffset.coerceIn(0L, fileSize)) + + // Read enough bytes for worst-case UTF-8: maxChars * 4 + val maxBytes = (maxChars.toLong() * 4).coerceAtMost(fileSize - safeOffset) + if (maxBytes <= 0) { + return WindowResult( + text = "", + startByte = safeOffset, + endByte = safeOffset, + isStartOfFile = safeOffset == 0L, + isEndOfFile = true, + ) + } + + val buffer = ByteBuffer.allocate(maxBytes.toInt()) + synchronized(channel) { + channel.position(safeOffset) + var totalRead = 0 + while (totalRead < maxBytes) { + val read = channel.read(buffer) + if (read == -1) break + totalRead += read + } + } + buffer.flip() + + val actualBytesRead = buffer.remaining() + if (actualBytesRead == 0) { + return WindowResult( + text = "", + startByte = safeOffset, + endByte = safeOffset, + isStartOfFile = safeOffset == 0L, + isEndOfFile = true, + ) + } + + // Decode bytes to string + val decoder = + charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + + val decoded = decoder.decode(buffer).toString() + + // Trim to maxChars + val trimmed = if (decoded.length > maxChars) decoded.substring(0, maxChars) else decoded + + // Calculate the byte length of the trimmed text + val trimmedBytes = trimmed.toByteArray(charset) + + val isAtStart = safeOffset == 0L + val isAtEnd = safeOffset + trimmedBytes.size >= fileSize + + // Snap to line boundaries + var text = trimmed + var startByteAdjust = 0 + + // Snap start: if not at file start, skip to after first newline + if (!isAtStart) { + val firstNewline = text.indexOf('\n') + if (firstNewline >= 0 && firstNewline < text.length - 1) { + val skipped = text.substring(0, firstNewline + 1) + startByteAdjust = skipped.toByteArray(charset).size + text = text.substring(firstNewline + 1) + } + } + + // Snap end: if not at file end, trim to last newline + if (!isAtEnd) { + val lastNewline = text.lastIndexOf('\n') + if (lastNewline >= 0) { + text = text.substring(0, lastNewline + 1) + } + } + + val actualStartByte = safeOffset + startByteAdjust + val actualEndByte = actualStartByte + text.toByteArray(charset).size + + return WindowResult( + text = text, + startByte = actualStartByte, + endByte = actualEndByte.coerceAtMost(fileSize), + isStartOfFile = actualStartByte == 0L, + isEndOfFile = actualEndByte >= fileSize, + ) + } + + /** + * Snap a byte offset to a UTF-8 character boundary by backing up + * past any continuation bytes (10xxxxxx pattern). + */ + private fun snapToCharBoundary(offset: Long): Long { + if (offset <= 0L || offset >= fileSize) return offset.coerceIn(0L, fileSize) + + val buf = ByteBuffer.allocate(1) + var pos = offset + // Back up at most 3 bytes (max UTF-8 continuation) + val minPos = (offset - 3).coerceAtLeast(0L) + + while (pos > minPos) { + synchronized(channel) { + channel.position(pos) + buf.clear() + channel.read(buf) + } + buf.flip() + val b = buf.get().toInt() and 0xFF + // If this byte is NOT a continuation byte, we're at a char boundary + if (b and 0xC0 != 0x80) { + return pos + } + pos-- + } + return pos + } + + override fun close() { + try { + channel.close() + } catch (_: Exception) { + } + try { + closeable?.close() + } catch (_: Exception) { + } + } + + companion object { + /** + * Create a FileWindowReader from a regular File using RandomAccessFile. + */ + fun fromFile(file: File): FileWindowReader { + val raf = RandomAccessFile(file, "r") + return FileWindowReader( + channel = raf.channel, + fileSize = raf.length(), + closeable = raf, + ) + } + + /** + * Create a FileWindowReader from a content:// URI using ContentResolver. + */ + fun fromContentUri( + contentResolver: ContentResolver, + uri: Uri, + ): FileWindowReader { + val pfd = + contentResolver.openFileDescriptor(uri, "r") + ?: throw IllegalArgumentException("Cannot open file descriptor for URI: $uri") + val fis = FileInputStream(pfd.fileDescriptor) + val channel = fis.channel + val size = channel.size() + return FileWindowReader( + channel = channel, + fileSize = size, + closeable = + object : Closeable { + override fun close() { + try { + fis.close() + } catch (_: Exception) { + } + try { + pfd.close() + } catch (_: Exception) { + } + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java index 3611b68a97..bd001c3a55 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallable.java @@ -54,6 +54,9 @@ public class ReadTextFileCallable implements Callable { private File cachedFile = null; + /** The resolved File used for reading (may be a cached copy for root files). */ + private File resolvedFile = null; + public ReadTextFileCallable( ContentResolver contentResolver, EditableFileAbstraction file, @@ -83,7 +86,9 @@ public ReturnedValueOnReadFile call() if (documentFile != null && documentFile.exists() && documentFile.canWrite()) { inputStream = contentResolver.openInputStream(documentFile.getUri()); } else { - inputStream = loadFile(FileUtils.fromContentUri(fileAbstraction.uri)); + File contentFile = FileUtils.fromContentUri(fileAbstraction.uri); + resolvedFile = contentFile; + inputStream = loadFile(contentFile); } } else { inputStream = contentResolver.openInputStream(fileAbstraction.uri); @@ -94,6 +99,7 @@ public ReturnedValueOnReadFile call() Objects.requireNonNull(hybridFileParcelable); File file = hybridFileParcelable.getFile(); + resolvedFile = file; inputStream = loadFile(file); break; @@ -121,7 +127,35 @@ public ReturnedValueOnReadFile call() fileContents = String.valueOf(buffer, 0, readChars); } - return new ReturnedValueOnReadFile(fileContents, cachedFile, tooLong); + FileWindowReader fileWindowReader = null; + long totalFileSize = 0L; + + if (tooLong) { + // Create a FileWindowReader for windowed mode + if (cachedFile != null) { + // Root file was cached locally + fileWindowReader = FileWindowReader.Companion.fromFile(cachedFile); + totalFileSize = cachedFile.length(); + } else if (resolvedFile != null) { + fileWindowReader = FileWindowReader.Companion.fromFile(resolvedFile); + totalFileSize = resolvedFile.length(); + } else if (fileAbstraction.scheme == EditableFileAbstraction.Scheme.CONTENT + && fileAbstraction.uri != null) { + try { + fileWindowReader = + FileWindowReader.Companion.fromContentUri(contentResolver, fileAbstraction.uri); + totalFileSize = fileWindowReader.getFileSize(); + } catch (Exception e) { + // Content provider doesn't support seekable file descriptors; + // windowed mode won't be available but the initial chunk is still shown + fileWindowReader = null; + totalFileSize = 0L; + } + } + } + + return new ReturnedValueOnReadFile( + fileContents, cachedFile, tooLong, fileWindowReader, totalFileSize); } private InputStream loadFile(File file) throws ShellNotRunningException, IOException { diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt index 361e541493..624495bcb2 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileTask.kt @@ -129,6 +129,16 @@ class ReadTextFileTask( if (value.fileIsTooLong) { textEditorActivity.setReadOnly() + + // Initialize windowed mode in the ViewModel + viewModel.isWindowed = true + viewModel.fileWindowReader = value.fileWindowReader + viewModel.totalFileSize = value.totalFileSize + viewModel.windowStartByte = 0L + // Estimate the end byte from the initial content + viewModel.windowEndByte = + value.fileContents.toByteArray(Charsets.UTF_8).size.toLong() + val snackbar = Snackbar.make( textEditorActivity.mainTextView, @@ -141,6 +151,9 @@ class ReadTextFileTask( .uppercase(Locale.getDefault()), ) { snackbar.dismiss() } snackbar.show() + + // Initialize windowed scroll listener after content is set + textEditorActivity.initWindowedScrollListener() } } } diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt index 99ddc3550e..65e93387f9 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFile.kt @@ -20,10 +20,15 @@ package com.amaze.filemanager.ui.activities.texteditor +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.read.FileWindowReader import java.io.File data class ReturnedValueOnReadFile( val fileContents: String, val cachedFile: File?, val fileIsTooLong: Boolean, + /** Non-null when fileIsTooLong — provides seekable access to the full file. */ + val fileWindowReader: FileWindowReader? = null, + /** Total file size in bytes, set when fileIsTooLong. */ + val totalFileSize: Long = 0L, ) diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java index 35856612df..47efde116f 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java @@ -54,12 +54,14 @@ import android.net.Uri; import android.os.Bundle; import android.text.Editable; +import android.text.Layout; import android.text.Spanned; import android.text.TextWatcher; import android.text.style.BackgroundColorSpan; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.view.ViewTreeObserver; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.view.inputmethod.EditorInfo; @@ -99,6 +101,9 @@ public class TextEditorActivity extends ThemedActivity private TextEditorActivityViewModel viewModel; + /** Scroll listener reference for windowed mode (so it can be removed if needed). */ + private ViewTreeObserver.OnScrollChangedListener windowedScrollListener; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -173,10 +178,18 @@ public void onCreate(Bundle savedInstanceState) { if (savedInstanceState.getBoolean(KEY_MONOFONT)) { mainTextView.setTypeface(inputTypefaceMono); } + // Restore windowed mode state after rotation + if (viewModel.isWindowed()) { + setReadOnly(); + initWindowedScrollListener(); + } } else { load(this); } initStatusBarResources(findViewById(R.id.textEditorRootView)); + + // Observe windowed-mode LiveData for new window content + observeWindowContent(); } @Override @@ -196,6 +209,12 @@ private void checkUnsavedChanges() { final TextEditorActivityViewModel viewModel = new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + // In windowed mode, the file is read-only — no unsaved changes possible + if (viewModel.isWindowed()) { + finish(); + return; + } + if (viewModel.getOriginal() != null && mainTextView.isShown() && mainTextView.getText() != null @@ -282,7 +301,14 @@ public boolean onPrepareOptionsMenu(Menu menu) { final TextEditorActivityViewModel viewModel = new ViewModelProvider(this).get(TextEditorActivityViewModel.class); - menu.findItem(R.id.save).setVisible(viewModel.getModified()); + boolean windowed = viewModel.isWindowed(); + + // Hide save in windowed mode; otherwise show based on modification state + menu.findItem(R.id.save).setVisible(!windowed && viewModel.getModified()); + + // Hide search in windowed mode (search only works on in-memory text) + menu.findItem(R.id.find).setVisible(!windowed); + menu.findItem(R.id.monofont).setChecked(inputTypefaceMono.equals(mainTextView.getTypeface())); return super.onPrepareOptionsMenu(menu); } @@ -378,6 +404,10 @@ public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { && charSequence.hashCode() == mainTextView.getText().hashCode()) { final TextEditorActivityViewModel viewModel = new ViewModelProvider(this).get(TextEditorActivityViewModel.class); + + // Skip modification tracking in windowed mode (text changes are window loads, not edits) + if (viewModel.isWindowed()) return; + final Timer oldTimer = viewModel.getTimer(); viewModel.setTimer(null); @@ -614,4 +644,88 @@ private void cleanSpans(TextEditorActivityViewModel viewModel) { } } } + + // ── Sliding Window Helpers ────────────────────────────────────────── + + /** + * Observe the ViewModel's windowContent LiveData. When a new window is loaded, replace the + * EditText content and adjust the scroll position for visual continuity. + */ + private void observeWindowContent() { + viewModel + .getWindowContent() + .observe( + this, + result -> { + if (result == null) return; + + // Determine an anchor: find the text line near the middle of the current viewport + int oldScrollY = scrollView.getScrollY(); + Layout oldLayout = mainTextView.getLayout(); + + // Replace text (TextWatcher will fire but windowed-mode guard skips modification + // tracking) + mainTextView.setText(result.getText()); + + // Adjust scroll position for visual continuity + mainTextView.post( + () -> { + Layout newLayout = mainTextView.getLayout(); + if (newLayout == null) return; + + TextEditorActivityViewModel.Direction direction; + // Infer direction from old scroll position + int viewportHeight = scrollView.getHeight(); + if (oldScrollY > viewportHeight / 2) { + // Was scrolling down → new content has overlap at the top → scroll to top + // area + // The overlap is ~50% of the window, so position at roughly 25% down + int targetLine = newLayout.getLineCount() / 4; + int targetY = newLayout.getLineTop(targetLine); + scrollView.scrollTo(0, targetY); + } else { + // Was scrolling up → new content has overlap at the bottom → scroll to bottom + // area + int targetLine = (newLayout.getLineCount() * 3) / 4; + int targetY = newLayout.getLineTop(targetLine); + scrollView.scrollTo(0, Math.max(0, targetY - viewportHeight)); + } + + invalidateOptionsMenu(); + }); + }); + } + + /** + * Called by ReadTextFileTask after initializing windowed mode. Sets up a scroll listener that + * triggers window loads when the user scrolls near the top or bottom edge. + */ + public void initWindowedScrollListener() { + if (windowedScrollListener != null) return; // already initialized + + windowedScrollListener = + () -> { + if (!viewModel.isWindowed()) return; + + int scrollY = scrollView.getScrollY(); + int viewportHeight = scrollView.getHeight(); + int contentHeight = mainTextView.getHeight(); + + if (contentHeight <= 0 || viewportHeight <= 0) return; + + // Threshold: 20% of viewport + int threshold = viewportHeight / 5; + + int distanceFromBottom = contentHeight - scrollY - viewportHeight; + int distanceFromTop = scrollY; + + if (distanceFromBottom < threshold) { + viewModel.loadWindow(TextEditorActivityViewModel.Direction.FORWARD); + } else if (distanceFromTop < threshold) { + viewModel.loadWindow(TextEditorActivityViewModel.Direction.BACKWARD); + } + }; + + scrollView.getViewTreeObserver().addOnScrollChangedListener(windowedScrollListener); + } } diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt index 83a57ef534..f7e95e24ad 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt @@ -20,8 +20,18 @@ package com.amaze.filemanager.ui.activities.texteditor +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.read.FileWindowReader +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.read.ReadTextFileCallable import com.amaze.filemanager.filesystem.EditableFileAbstraction +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.util.Timer @@ -55,4 +65,79 @@ class TextEditorActivityViewModel : ViewModel() { var timer: Timer? = null var file: EditableFileAbstraction? = null + + // ── Sliding window state ────────────────────────────────────────── + + /** Whether the editor is in windowed (read-only) mode for large files. */ + var isWindowed = false + + /** Seekable reader for the underlying file; lives in ViewModel to survive rotation. */ + var fileWindowReader: FileWindowReader? = null + + /** Byte offset of the start of the currently displayed window. */ + var windowStartByte: Long = 0L + + /** Byte offset just past the end of the currently displayed window. */ + var windowEndByte: Long = 0L + + /** Total file size in bytes. */ + var totalFileSize: Long = 0L + + /** Dispatcher for IO operations. Override in tests with a test dispatcher. */ + var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + private val _windowContent = MutableLiveData() + + /** Observed by the Activity to update the EditText when a new window is loaded. */ + val windowContent: LiveData = _windowContent + + private var windowLoadJob: Job? = null + + enum class Direction { FORWARD, BACKWARD } + + /** + * Loads the next or previous window of text from the file. + * Debounced: if a load is already in flight, the call is ignored. + */ + fun loadWindow(direction: Direction) { + if (windowLoadJob?.isActive == true) return // debounce + val reader = fileWindowReader ?: return + + val windowSize = windowEndByte - windowStartByte + val halfWindow = windowSize / 2 + + val targetOffset = + when (direction) { + Direction.FORWARD -> { + // Don't shift if already at end of file + if (windowEndByte >= totalFileSize) return + windowStartByte + halfWindow + } + Direction.BACKWARD -> { + // Don't shift if already at start of file + if (windowStartByte <= 0L) return + maxOf(0L, windowStartByte - halfWindow) + } + } + + windowLoadJob = + viewModelScope.launch { + val result = + withContext(ioDispatcher) { + reader.readWindow(targetOffset, ReadTextFileCallable.MAX_FILE_SIZE_CHARS) + } + windowStartByte = result.startByte + windowEndByte = result.endByte + _windowContent.value = result + } + } + + override fun onCleared() { + super.onCleared() + windowLoadJob?.cancel() + try { + fileWindowReader?.close() + } catch (_: Exception) { + } + } } diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt new file mode 100644 index 0000000000..e60bb99f01 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt @@ -0,0 +1,427 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.texteditor.read + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Unit tests for [FileWindowReader]. + * + * These are pure JVM tests (no Android/Robolectric needed) since FileWindowReader + * only depends on java.io and java.nio classes. + */ +class FileWindowReaderTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var testFile: File + + @Before + fun setUp() { + testFile = tempFolder.newFile("test.txt") + } + + // ── Basic reading ──────────────────────────────────────────────── + + @Test + fun testReadEmptyFile() { + testFile.writeText("") + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertEquals("", result.text) + assertEquals(0L, result.startByte) + assertEquals(0L, result.endByte) + assertTrue(result.isStartOfFile) + assertTrue(result.isEndOfFile) + } + } + + @Test + fun testReadSmallFileFitsInWindow() { + val content = "Hello, World!\nSecond line\nThird line\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertEquals(content, result.text) + assertEquals(0L, result.startByte) + assertTrue(result.isStartOfFile) + assertTrue(result.isEndOfFile) + } + } + + @Test + fun testReadSingleLineFile() { + val content = "No newline at end" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertEquals(content, result.text) + assertTrue(result.isStartOfFile) + assertTrue(result.isEndOfFile) + } + } + + @Test + fun testFileSizeCorrect() { + val content = "Hello" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + assertEquals(content.toByteArray().size.toLong(), it.fileSize) + } + } + + // ── Window limiting ────────────────────────────────────────────── + + @Test + fun testMaxCharsLimitsOutput() { + // Create content with multiple lines, each larger than 5 chars + val content = "Line1\nLine2\nLine3\nLine4\nLine5\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Request only 12 chars — should get at most 12 chars snapped to line boundaries + val result = it.readWindow(0, 12) + assertTrue(result.text.length <= 12) + assertTrue(result.isStartOfFile) + // With line snapping, it shouldn't include trailing partial lines + assertTrue(result.text.endsWith("\n")) + } + } + + @Test + fun testWindowFromStartOfFile() { + val lines = (1..100).map { "Line number $it here\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 200) + assertTrue(result.isStartOfFile) + assertFalse(result.isEndOfFile) + assertTrue(result.text.startsWith("Line number 1 here\n")) + assertTrue(result.text.endsWith("\n")) + } + } + + // ── Mid-file window with line snapping ─────────────────────────── + + @Test + fun testWindowFromMiddleSnapsToLineStart() { + val lines = (1..20).map { "Line $it\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Seek to the middle of the file (byte offset in the middle of a line) + val midOffset = content.toByteArray().size / 2L + val result = it.readWindow(midOffset, 200) + + // When starting mid-file, the first partial line should be skipped + assertFalse(result.isStartOfFile) + // The result should start at a complete line + assertTrue(result.text.startsWith("Line")) + assertTrue(result.text.endsWith("\n")) + } + } + + @Test + fun testWindowEndSnapsToNewline() { + // Large content so the window can't contain it all + val lines = (1..1000).map { "Line number $it with some padding text to make it longer\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Read a small window from the start + val result = it.readWindow(0, 200) + assertTrue(result.isStartOfFile) + assertFalse(result.isEndOfFile) + // End should be at a line boundary + assertTrue(result.text.endsWith("\n")) + // No partial lines + assertFalse(result.text.trimEnd('\n').contains("Line number").not()) + } + } + + // ── Window reading near end of file ────────────────────────────── + + @Test + fun testWindowNearEndOfFile() { + val lines = (1..50).map { "Line $it\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val fileBytes = content.toByteArray() + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Read from near the end — request more chars than remain + val nearEnd = (fileBytes.size - 30L).coerceAtLeast(0L) + val result = it.readWindow(nearEnd, 10000) + assertTrue(result.isEndOfFile) + assertFalse(result.isStartOfFile) + // Should contain the last line + assertTrue(result.text.contains("Line 50\n")) + } + } + + @Test + fun testWindowAtExactEndOfFile() { + val content = "Hello\nWorld\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(content.toByteArray().size.toLong(), 1024) + assertEquals("", result.text) + assertTrue(result.isEndOfFile) + } + } + + // ── UTF-8 multi-byte character handling ────────────────────────── + + @Test + fun testUtf8MultiByteBoundary() { + // Use multibyte UTF-8 characters (emoji = 4 bytes each) + val content = "Hello\n\uD83D\uDE00\uD83D\uDE01\uD83D\uDE02\n" // 😀😁😂 + testFile.writeBytes(content.toByteArray(Charsets.UTF_8)) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertTrue(result.text.contains("😀")) + assertTrue(result.text.contains("😁")) + assertTrue(result.text.contains("😂")) + } + } + + @Test + fun testUtf8SeekIntoMiddleOfMultibyteChar() { + // 2-byte UTF-8 chars: é = C3 A9 (2 bytes) + val content = "café\ncafé\ncafé\n" + testFile.writeBytes(content.toByteArray(Charsets.UTF_8)) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Seek to byte 4 which is the second byte of 'é' in "café" + // The snapToCharBoundary should back up to byte 3 + val result = it.readWindow(4, 1024) + // Should be valid UTF-8 text, no replacement characters + assertFalse(result.text.contains("\uFFFD")) + } + } + + @Test + fun testCjkCharacters() { + // 3-byte UTF-8 chars: Chinese characters + val content = "第一行\n第二行\n第三行\n" + testFile.writeBytes(content.toByteArray(Charsets.UTF_8)) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertTrue(result.text.contains("第一行")) + assertTrue(result.text.contains("第三行")) + assertTrue(result.isStartOfFile) + assertTrue(result.isEndOfFile) + } + } + + // ── Consecutive window reads (simulating scroll) ───────────────── + + @Test + fun testConsecutiveForwardWindowReads() { + val lines = (1..200).map { "Line $it\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // First window from start + val first = it.readWindow(0, 100) + assertTrue(first.isStartOfFile) + assertFalse(first.isEndOfFile) + assertTrue(first.text.isNotEmpty()) + + // Second window starting at half of first window's end + val midPoint = (first.endByte - first.startByte) / 2 + first.startByte + val second = it.readWindow(midPoint, 100) + assertFalse(second.isStartOfFile) + // Should have progressed past the first window start + assertTrue(second.startByte > first.startByte) + } + } + + @Test + fun testOverlappingWindowsShareContent() { + val lines = (1..200).map { "Line $it content here\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val first = it.readWindow(0, 500) + // Read second window with 50% overlap + val overlapStart = first.startByte + (first.endByte - first.startByte) / 2 + val second = it.readWindow(overlapStart, 500) + + // There should be overlapping content between the two windows + val firstLines = first.text.lines().filter { l -> l.isNotEmpty() } + val secondLines = second.text.lines().filter { l -> l.isNotEmpty() } + + val overlap = firstLines.intersect(secondLines.toSet()) + assertTrue( + "Windows should overlap, got first=${firstLines.size} second=${secondLines.size} overlap=${overlap.size}", + overlap.isNotEmpty(), + ) + } + } + + // ── Edge cases ─────────────────────────────────────────────────── + + @Test + fun testNegativeOffset() { + val content = "Hello\nWorld\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Should clamp to 0 + val result = it.readWindow(-100, 1024) + assertTrue(result.isStartOfFile) + assertEquals(content, result.text) + } + } + + @Test + fun testOffsetBeyondFileSize() { + val content = "Hello\nWorld\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(100000, 1024) + assertEquals("", result.text) + assertTrue(result.isEndOfFile) + } + } + + @Test + fun testMaxCharsZero() { + val content = "Hello\nWorld\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // maxChars=0 → maxBytes will be 0, should return empty + val result = it.readWindow(0, 0) + assertEquals("", result.text) + } + } + + @Test + fun testFileWithOnlyNewlines() { + val content = "\n\n\n\n\n" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 1024) + assertEquals(content, result.text) + assertTrue(result.isStartOfFile) + assertTrue(result.isEndOfFile) + } + } + + @Test + fun testVeryLongSingleLine() { + // Single line with no newlines at all + val content = "A".repeat(10000) + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + // Request a small window — since there are no newlines, end-snap + // won't find a newline. Because we're at start and end, full content + // up to maxChars is returned. + val result = it.readWindow(0, 500) + assertTrue(result.text.length <= 500) + assertTrue(result.isStartOfFile) + } + } + + // ── Close behavior ─────────────────────────────────────────────── + + @Test + fun testCloseReleasesChannel() { + val content = "Hello" + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.close() + // Double close should not throw + reader.close() + } + + // ── fromFile factory ───────────────────────────────────────────── + + @Test + fun testFromFileFactory() { + val content = "Factory test\nLine 2\n" + testFile.writeText(content) + FileWindowReader.fromFile(testFile).use { reader -> + assertEquals(content.toByteArray().size.toLong(), reader.fileSize) + val result = reader.readWindow(0, 1024) + assertEquals(content, result.text) + } + } + + // ── Byte offset tracking ───────────────────────────────────────── + + @Test + fun testByteOffsetsAreConsistent() { + val lines = (1..50).map { "Line $it\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(0, 100) + // endByte should equal startByte + byte length of returned text + val expectedEndByte = result.startByte + result.text.toByteArray(Charsets.UTF_8).size + assertEquals(expectedEndByte, result.endByte) + } + } + + @Test + fun testByteOffsetsForMidFileRead() { + val lines = (1..100).map { "Line $it\n" } + val content = lines.joinToString("") + testFile.writeText(content) + val reader = FileWindowReader.fromFile(testFile) + reader.use { + val result = it.readWindow(50, 100) + // startByte should be >= 50 (snapped forward past partial line) + assertTrue(result.startByte >= 50) + // endByte should be > startByte + assertTrue(result.endByte > result.startByte) + // Byte range should match text length + val textBytes = result.text.toByteArray(Charsets.UTF_8).size + assertEquals(result.endByte, result.startByte + textBytes) + } + } +} diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt index f120e7f542..21ec621007 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt @@ -35,7 +35,9 @@ import com.amaze.filemanager.filesystem.RandomPathGenerator import com.amaze.filemanager.shadows.ShadowMultiDex import com.amaze.filemanager.ui.activities.texteditor.ReturnedValueOnReadFile import org.junit.Assert +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.Shadows import org.robolectric.annotation.Config @@ -49,6 +51,9 @@ import kotlin.random.Random sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], ) class ReadTextFileCallableTest { + @get:Rule + val tempFolder = TemporaryFolder() + /** * Test read an empty file with [ReadTextFileCallable] */ @@ -150,4 +155,101 @@ class ReadTextFileCallableTest { val path = RandomPathGenerator.generateRandomPath(Random(123), 50) return Uri.parse("content://com.amaze.filemanager.test/$path/foobar.txt") } + + // ── New tests for windowed mode (file:// URI with FileWindowReader) ── + + /** + * Test that reading a big file via file:// URI produces a FileWindowReader + * and correct total file size. + */ + @Test + fun testReadBigFileViaFileUriCreatesWindowReader() { + val random = Random(456) + val letters = ('A'..'Z').toSet() + ('a'..'z').toSet() + val bigContent = List(MAX_FILE_SIZE_CHARS * 2) { letters.random(random) }.joinToString("") + + val file = tempFolder.newFile("bigfile.txt") + file.writeText(bigContent) + + val ctx = ApplicationProvider.getApplicationContext() + val uri = Uri.fromFile(file) + val task = + ReadTextFileCallable( + ctx.contentResolver, + EditableFileAbstraction(ctx, uri), + tempFolder.root, + false, + ) + val result = task.call() + + Assert.assertTrue("File should be too long", result.fileIsTooLong) + Assert.assertEquals( + bigContent.substring(0, MAX_FILE_SIZE_CHARS), + result.fileContents, + ) + Assert.assertNotNull("FileWindowReader should be created for file:// URI", result.fileWindowReader) + Assert.assertEquals(file.length(), result.totalFileSize) + + // Verify the reader works + val windowResult = result.fileWindowReader!!.readWindow(0, 100) + Assert.assertTrue(windowResult.text.isNotEmpty()) + Assert.assertTrue(windowResult.isStartOfFile) + + result.fileWindowReader!!.close() + } + + /** + * Test that reading a small file via file:// URI does NOT create a FileWindowReader. + */ + @Test + fun testReadSmallFileViaFileUriNoWindowReader() { + val content = "Small file content\n" + + val file = tempFolder.newFile("smallfile.txt") + file.writeText(content) + + val ctx = ApplicationProvider.getApplicationContext() + val uri = Uri.fromFile(file) + val task = + ReadTextFileCallable( + ctx.contentResolver, + EditableFileAbstraction(ctx, uri), + tempFolder.root, + false, + ) + val result = task.call() + + Assert.assertFalse("File should not be too long", result.fileIsTooLong) + Assert.assertEquals(content, result.fileContents) + Assert.assertNull("FileWindowReader should be null for small files", result.fileWindowReader) + Assert.assertEquals(0L, result.totalFileSize) + } + + /** + * Test that big file via content:// URI gracefully handles missing seekable descriptor + * (fileWindowReader remains null). + */ + @Test + fun testReadBigFileViaContentUriFallsBackGracefully() { + val random = Random(789) + val letters = ('A'..'Z').toSet() + ('a'..'z').toSet() + val bigContent = List(MAX_FILE_SIZE_CHARS * 2) { letters.random(random) }.joinToString("") + + val uri = generatePath() + val ctx = ApplicationProvider.getApplicationContext() + val cr = ctx.contentResolver + Shadows.shadowOf(cr).registerInputStream(uri, ByteArrayInputStream(bigContent.toByteArray())) + + val task = ReadTextFileCallable(cr, EditableFileAbstraction(ctx, uri), null, false) + val result = task.call() + + Assert.assertTrue("File should be too long", result.fileIsTooLong) + // Content provider shadow doesn't support openFileDescriptor, + // so FileWindowReader creation should have failed gracefully + Assert.assertNull( + "FileWindowReader should be null when content provider doesn't support seek", + result.fileWindowReader, + ) + Assert.assertEquals(0L, result.totalFileSize) + } } diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt index b08a421056..ceb72c9221 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ftp/FtpReceiverTest.kt @@ -99,7 +99,7 @@ class FtpReceiverTest { * Test [Context.startForegroundService()] called for post-Nougat Androids. */ @Test - @Config(minSdk = O) + @Config(minSdk = O, maxSdk = Build.VERSION_CODES.R) fun testStartForegroundServiceCalled() { val ctx = AppConfig.getInstance() val spy = spyk(ctx) diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFileTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFileTest.kt new file mode 100644 index 0000000000..d7863ff1fd --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/ReturnedValueOnReadFileTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [ReturnedValueOnReadFile] data class, including new windowed mode fields. + */ +class ReturnedValueOnReadFileTest { + @Test + fun testDefaultValues() { + val result = ReturnedValueOnReadFile("content", null, false) + assertEquals("content", result.fileContents) + assertNull(result.cachedFile) + assertFalse(result.fileIsTooLong) + assertNull(result.fileWindowReader) + assertEquals(0L, result.totalFileSize) + } + + @Test + fun testExplicitNullReader() { + val result = ReturnedValueOnReadFile("content", null, true, null, 0L) + assertTrue(result.fileIsTooLong) + assertNull(result.fileWindowReader) + assertEquals(0L, result.totalFileSize) + } + + @Test + fun testEqualityWithDefaultParams() { + val a = ReturnedValueOnReadFile("hello", null, false) + val b = ReturnedValueOnReadFile("hello", null, false, null, 0L) + assertEquals(a, b) + } + + @Test + fun testEqualityDifferentContent() { + val a = ReturnedValueOnReadFile("hello", null, false) + val b = ReturnedValueOnReadFile("world", null, false) + assertFalse(a == b) + } + + @Test + fun testCopyWithTotalFileSize() { + val original = ReturnedValueOnReadFile("content", null, false) + val modified = original.copy(fileIsTooLong = true, totalFileSize = 999L) + assertTrue(modified.fileIsTooLong) + assertEquals(999L, modified.totalFileSize) + assertEquals("content", modified.fileContents) + } + + @Test + fun testToString() { + val result = ReturnedValueOnReadFile("hi", null, false) + val str = result.toString() + assertTrue(str.contains("fileContents=hi")) + assertTrue(str.contains("fileIsTooLong=false")) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModelTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModelTest.kt new file mode 100644 index 0000000000..876cc0ac65 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModelTest.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import android.os.Build +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.P +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.amaze.filemanager.asynchronous.asynctasks.texteditor.read.FileWindowReader +import com.amaze.filemanager.shadows.ShadowMultiDex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File + +/** + * Tests for [TextEditorActivityViewModel] windowed mode state and loadWindow logic. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config( + shadows = [ShadowMultiDex::class], + sdk = [LOLLIPOP, P, Build.VERSION_CODES.R], +) +class TextEditorActivityViewModelTest { + @get:Rule + val instantTaskRule = InstantTaskExecutorRule() + + @get:Rule + val tempFolder = TemporaryFolder() + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // ── Default state ──────────────────────────────────────────────── + + @Test + fun testDefaultStateNotWindowed() { + val vm = TextEditorActivityViewModel() + assertFalse(vm.isWindowed) + assertNull(vm.fileWindowReader) + assertEquals(0L, vm.windowStartByte) + assertEquals(0L, vm.windowEndByte) + assertEquals(0L, vm.totalFileSize) + assertNull(vm.windowContent.value) + } + + @Test + fun testDefaultNonWindowedState() { + val vm = TextEditorActivityViewModel() + assertNull(vm.original) + assertNull(vm.cacheFile) + assertFalse(vm.modified) + assertEquals(-1, vm.current) + assertEquals(0, vm.line) + assertTrue(vm.searchResultIndices.isEmpty()) + } + + // ── Windowed state initialization ──────────────────────────────── + + @Test + fun testInitializeWindowedMode() { + val vm = TextEditorActivityViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.isWindowed = true + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + vm.windowStartByte = 0L + vm.windowEndByte = 1000L + + assertTrue(vm.isWindowed) + assertNotNull(vm.fileWindowReader) + assertEquals(file.length(), vm.totalFileSize) + assertEquals(0L, vm.windowStartByte) + assertEquals(1000L, vm.windowEndByte) + + reader.close() + } + + // ── loadWindow: forward ────────────────────────────────────────── + + @Test + fun testLoadWindowForward() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + vm.windowStartByte = 0L + vm.windowEndByte = 200L + + // Observe LiveData + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.FORWARD) + advanceUntilIdle() + + assertNotNull(result) + assertTrue("Window should have shifted forward", vm.windowStartByte > 0L) + assertTrue(result!!.text.isNotEmpty()) + + reader.close() + } + + // ── loadWindow: backward ───────────────────────────────────────── + + @Test + fun testLoadWindowBackward() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + // Start at a mid-file position + vm.windowStartByte = 500L + vm.windowEndByte = 700L + + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.BACKWARD) + advanceUntilIdle() + + assertNotNull(result) + assertTrue("Window should have shifted backward", vm.windowStartByte < 500L) + assertTrue(result!!.text.isNotEmpty()) + + reader.close() + } + + // ── loadWindow: no-op at boundaries ────────────────────────────── + + @Test + fun testLoadWindowForwardNoOpAtEndOfFile() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + // Position at the end + vm.windowStartByte = file.length() - 100 + vm.windowEndByte = file.length() + + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.FORWARD) + advanceUntilIdle() + + // Should not have emitted anything (no-op) + assertNull(result) + + reader.close() + } + + @Test + fun testLoadWindowBackwardNoOpAtStartOfFile() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + vm.windowStartByte = 0L + vm.windowEndByte = 200L + + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.BACKWARD) + advanceUntilIdle() + + // Should not have emitted anything (no-op) + assertNull(result) + + reader.close() + } + + // ── loadWindow: no-op when no reader ───────────────────────────── + + @Test + fun testLoadWindowNoOpWithoutReader() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + // No fileWindowReader set + vm.fileWindowReader = null + + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.FORWARD) + advanceUntilIdle() + + assertNull(result) + } + + // ── Window byte offsets updated after load ─────────────────────── + + @Test + fun testWindowByteOffsetsUpdatedAfterLoad() = + runTest(testDispatcher) { + val vm = createWindowedViewModel() + val file = createLargeTestFile() + val reader = FileWindowReader.fromFile(file) + + vm.fileWindowReader = reader + vm.totalFileSize = file.length() + vm.windowStartByte = 0L + vm.windowEndByte = 400L + + val originalStart = vm.windowStartByte + val originalEnd = vm.windowEndByte + + var result: FileWindowReader.WindowResult? = null + vm.windowContent.observeForever { result = it } + + vm.loadWindow(TextEditorActivityViewModel.Direction.FORWARD) + advanceUntilIdle() + + assertNotNull(result) + // Offsets should reflect the new window position from the result + assertEquals(result!!.startByte, vm.windowStartByte) + assertEquals(result!!.endByte, vm.windowEndByte) + // Should have shifted + assertTrue(vm.windowStartByte > originalStart || vm.windowEndByte > originalEnd) + + reader.close() + } + + // ── onCleared closes reader ────────────────────────────────────── + + @Test + fun testOnClearedClosesReader() { + val vm = TextEditorActivityViewModel() + val file = createSmallTestFile() + val reader = FileWindowReader.fromFile(file) + vm.fileWindowReader = reader + + // Trigger onCleared via reflection (it's protected) + val method = + TextEditorActivityViewModel::class.java + .getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(vm) + + // Verify the reader was closed (reading should throw or return empty) + var threwException = false + try { + reader.readWindow(0, 100) + } catch (e: Exception) { + threwException = true + } + assertTrue("Reader should be closed after onCleared", threwException) + } + + // ── Direction enum values ──────────────────────────────────────── + + @Test + fun testDirectionEnum() { + val values = TextEditorActivityViewModel.Direction.values() + assertEquals(2, values.size) + assertEquals(TextEditorActivityViewModel.Direction.FORWARD, values[0]) + assertEquals(TextEditorActivityViewModel.Direction.BACKWARD, values[1]) + } + + // ── Helpers ────────────────────────────────────────────────────── + + /** Creates a ViewModel configured for windowed mode with testDispatcher for IO. */ + private fun createWindowedViewModel(): TextEditorActivityViewModel { + val vm = TextEditorActivityViewModel() + vm.isWindowed = true + vm.ioDispatcher = testDispatcher + return vm + } + + private fun createLargeTestFile(): File { + val file = tempFolder.newFile("large.txt") + val lines = (1..500).map { "Line number $it with some padding content\n" } + file.writeText(lines.joinToString("")) + return file + } + + private fun createSmallTestFile(): File { + val file = tempFolder.newFile("small.txt") + file.writeText("Hello\nWorld\n") + return file + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e4f84059f..b5e2bd519a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ androidXPalette = "1.0.0" androidXCardView = "1.0.0" androidXConstraintLayout = "1.1.3" androidXBiometric = "1.1.0" +androidXLifecycle = "2.6.2" androidXTest = "1.6.1" androidXTestRunner = "1.6.2" androidXTestExt = "1.2.1" @@ -95,6 +96,8 @@ androidX-palette = { module = "androidx.palette:palette-ktx", version.ref = "and androidX-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidXPref" } androidX-vectordrawable-animated = { module = "androidx.vectordrawable:vectordrawable-animated", version.ref = "vectordrawableAnimated" } androidX-biometric = { module = "androidx.biometric:biometric", version.ref = "androidXBiometric" } +androidX-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidXLifecycle" } +androidX-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidXLifecycle" } androidX-test-core = { module = "androidx.test:core", version.ref = "androidXTest" } androidX-test-runner = { module = "androidx.test:runner", version.ref = "androidXTestRunner" } @@ -124,6 +127,8 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlinStdlibJdk8" } kotlin-coroutine-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } libsu-core = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } libsu-io = { module = "com.github.topjohnwu.libsu:io", version.ref = "libsu" } From e02df5c828e0a1b88bfa8743dfd7372a73e0821b Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Wed, 25 Feb 2026 23:49:16 +0800 Subject: [PATCH 2/4] Implement support for Markdown preview in TextEditorActivity Uses Commonmark for Markdown parsing and rendering. Due to the way Commonmark works, it cannot support content streaming and update in realtime; user needs to let TextEditorActivity to load as much content as wish. --- app/build.gradle | 4 + .../texteditor/read/FileWindowReader.kt | 21 +- .../texteditor/read/ReadTextFileCallable.java | 2 +- .../texteditor/MarkdownHtmlGenerator.kt | 97 +++++++ .../texteditor/TextEditorActivity.java | 65 ++++- .../texteditor/TextEditorActivityViewModel.kt | 5 + app/src/main/res/layout/search.xml | 41 ++- app/src/main/res/menu/text.xml | 6 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rHK/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../texteditor/read/FileWindowReaderTest.kt | 106 +++++-- .../read/ReadTextFileCallableTest.kt | 2 +- .../texteditor/MarkdownHtmlGeneratorTest.kt | 261 ++++++++++++++++++ .../texteditor/ReturnedValueOnReadFileTest.kt | 23 +- .../TextEditorActivityViewModelTest.kt | 18 ++ .../scripts/create-huge-complex-markdown.sh | 84 ++++++ .../create-huge-markdown-with-images.sh | 5 + gradle/libs.versions.toml | 5 + 21 files changed, 697 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGenerator.kt create mode 100644 app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGeneratorTest.kt create mode 100644 app/src/test/scripts/create-huge-complex-markdown.sh create mode 100644 app/src/test/scripts/create-huge-markdown-with-images.sh diff --git a/app/build.gradle b/app/build.gradle index 4ab82d6613..e5e2ed9a44 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -261,6 +261,10 @@ dependencies { implementation libs.gson implementation libs.amaze.trashBin + + implementation libs.commonmark + implementation libs.commonmark.ext.gfm.tables + implementation libs.commonmark.ext.gfm.strikethrough } kotlin { diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt index 6c15835f7d..78434ae8c7 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt @@ -64,6 +64,7 @@ class FileWindowReader( * 4. Snaps the end to the last newline (unless at file end) * 5. Returns the decoded string and actual byte range consumed */ + @Suppress("LongMethod") fun readWindow( byteOffset: Long, maxChars: Int, @@ -161,7 +162,7 @@ class FileWindowReader( * past any continuation bytes (10xxxxxx pattern). */ private fun snapToCharBoundary(offset: Long): Long { - if (offset <= 0L || offset >= fileSize) return offset.coerceIn(0L, fileSize) + if (offset !in 1.. { - public static final int MAX_FILE_SIZE_CHARS = 50 * 1024; + public static final int MAX_FILE_SIZE_CHARS = 100 * 1024; private final ContentResolver contentResolver; private final EditableFileAbstraction fileAbstraction; diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGenerator.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGenerator.kt new file mode 100644 index 0000000000..b8edacc1a3 --- /dev/null +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGenerator.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer + +/** + * Utility for Markdown preview in the text editor. + * Pure functions with no Android dependencies — easy to unit-test. + */ +object MarkdownHtmlGenerator { + /** + * Returns `true` if [fileName] ends with `.md` or `.markdown` (case-insensitive). + */ + @JvmStatic + fun isMarkdownFile(fileName: String?): Boolean { + if (fileName == null) return false + val lower = fileName.lowercase() + return lower.endsWith(".md") || lower.endsWith(".markdown") + } + + /** + * Parse Markdown source text and return the rendered HTML body fragment. + */ + @JvmStatic + fun renderToHtml(markdownSource: String): String { + val extensions = listOf(TablesExtension.create(), StrikethroughExtension.create()) + val parser = Parser.builder().extensions(extensions).build() + val document = parser.parse(markdownSource) + val renderer = HtmlRenderer.builder().extensions(extensions).build() + return renderer.render(document) + } + + /** + * Wrap an HTML body fragment with a full HTML document including theme-aware CSS. + * + * FIXME: Move template to strings.xml + * FIXME: Use Android/Material native Color constants for easy maintenance + * + * @param bodyHtml the rendered Markdown HTML fragment + * @param isDarkTheme true for dark/black theme, false for light theme + * @return a complete HTML document string + */ + @JvmStatic + fun wrapWithBaseHtml( + bodyHtml: String, + isDarkTheme: Boolean, + ): String { + val bgColor = if (isDarkTheme) "#1a1a1a" else "#ffffff" + val textColor = if (isDarkTheme) "#e0e0e0" else "#212121" + val linkColor = if (isDarkTheme) "#82b1ff" else "#1565c0" + val codeBg = if (isDarkTheme) "#2d2d2d" else "#f5f5f5" + val borderColor = if (isDarkTheme) "#444444" else "#dddddd" + val quoteColor = if (isDarkTheme) "#aaaaaa" else "#666666" + + return """ + + + +$bodyHtml +""" + } +} diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java index 47efde116f..185a38064b 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivity.java @@ -66,6 +66,7 @@ import android.view.animation.AnimationUtils; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; +import android.webkit.WebView; import android.widget.ScrollView; import android.widget.Toast; @@ -86,12 +87,14 @@ public class TextEditorActivity extends ThemedActivity private Typeface inputTypefaceMono; private androidx.appcompat.widget.Toolbar toolbar; ScrollView scrollView; + private WebView markdownWebView; private SearchTextTask searchTextTask; private static final String KEY_MODIFIED_TEXT = "modified"; private static final String KEY_INDEX = "index"; private static final String KEY_ORIGINAL_TEXT = "original"; private static final String KEY_MONOFONT = "monofont"; + private static final String KEY_MARKDOWN_PREVIEW = "markdown_preview"; private ConstraintLayout searchViewLayout; public AppCompatImageButton upButton; @@ -134,6 +137,8 @@ public void onCreate(Bundle savedInstanceState) { } mainTextView = findViewById(R.id.textEditorMainEditText); scrollView = findViewById(R.id.textEditorScrollView); + markdownWebView = findViewById(R.id.textEditorMarkdownWebView); + markdownWebView.getSettings().setJavaScriptEnabled(false); final Uri uri = getIntent().getData(); if (uri != null) { @@ -178,6 +183,11 @@ public void onCreate(Bundle savedInstanceState) { if (savedInstanceState.getBoolean(KEY_MONOFONT)) { mainTextView.setTypeface(inputTypefaceMono); } + // Restore markdown preview state + if (savedInstanceState.getBoolean(KEY_MARKDOWN_PREVIEW, false)) { + viewModel.setMarkdownPreviewEnabled(true); + toggleMarkdownPreview(true); + } // Restore windowed mode state after rotation if (viewModel.isWindowed()) { setReadOnly(); @@ -203,6 +213,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(KEY_INDEX, mainTextView.getScrollY()); outState.putString(KEY_ORIGINAL_TEXT, viewModel.getOriginal()); outState.putBoolean(KEY_MONOFONT, inputTypefaceMono.equals(mainTextView.getTypeface())); + outState.putBoolean(KEY_MARKDOWN_PREVIEW, viewModel.getMarkdownPreviewEnabled()); } private void checkUnsavedChanges() { @@ -309,6 +320,11 @@ public boolean onPrepareOptionsMenu(Menu menu) { // Hide search in windowed mode (search only works on in-memory text) menu.findItem(R.id.find).setVisible(!windowed); + // Show markdown preview item only for .md/.markdown files + MenuItem markdownItem = menu.findItem(R.id.markdown_preview); + markdownItem.setVisible(isMarkdownFile()); + markdownItem.setChecked(viewModel.getMarkdownPreviewEnabled()); + menu.findItem(R.id.monofont).setChecked(inputTypefaceMono.equals(mainTextView.getTypeface())); return super.onPrepareOptionsMenu(menu); } @@ -362,6 +378,11 @@ public boolean onOptionsItemSelected(MenuItem item) { } else if (item.getItemId() == R.id.monofont) { item.setChecked(!item.isChecked()); mainTextView.setTypeface(item.isChecked() ? inputTypefaceMono : inputTypefaceDefault); + } else if (item.getItemId() == R.id.markdown_preview) { + boolean newState = !item.isChecked(); + item.setChecked(newState); + viewModel.setMarkdownPreviewEnabled(newState); + toggleMarkdownPreview(newState); } else { return false; } @@ -661,7 +682,6 @@ private void observeWindowContent() { // Determine an anchor: find the text line near the middle of the current viewport int oldScrollY = scrollView.getScrollY(); - Layout oldLayout = mainTextView.getLayout(); // Replace text (TextWatcher will fire but windowed-mode guard skips modification // tracking) @@ -673,7 +693,6 @@ private void observeWindowContent() { Layout newLayout = mainTextView.getLayout(); if (newLayout == null) return; - TextEditorActivityViewModel.Direction direction; // Infer direction from old scroll position int viewportHeight = scrollView.getHeight(); if (oldScrollY > viewportHeight / 2) { @@ -728,4 +747,46 @@ public void initWindowedScrollListener() { scrollView.getViewTreeObserver().addOnScrollChangedListener(windowedScrollListener); } + + // ── Markdown Preview Helpers ──────────────────────────────────────── + + /** Returns true if the currently opened file has a Markdown extension (.md or .markdown). */ + private boolean isMarkdownFile() { + EditableFileAbstraction file = viewModel.getFile(); + if (file == null) return false; + return MarkdownHtmlGenerator.isMarkdownFile(file.name); + } + + /** + * Toggle between Markdown preview (WebView) and the normal EditText editor. + * + * @param enabled true to show the WebView with rendered Markdown; false to show EditText + */ + private void toggleMarkdownPreview(boolean enabled) { + if (enabled) { + renderMarkdownToWebView(); + scrollView.setVisibility(View.GONE); + markdownWebView.setVisibility(View.VISIBLE); + } else { + markdownWebView.setVisibility(View.GONE); + scrollView.setVisibility(View.VISIBLE); + } + invalidateOptionsMenu(); + } + + /** + * Parse the current EditText content as Markdown using commonmark, render to HTML, and load it + * into the WebView. + */ + private void renderMarkdownToWebView() { + String markdownSource = ""; + if (mainTextView.getText() != null) { + markdownSource = mainTextView.getText().toString(); + } + + String bodyHtml = MarkdownHtmlGenerator.renderToHtml(markdownSource); + boolean isDark = getAppTheme().equals(AppTheme.DARK) || getAppTheme().equals(AppTheme.BLACK); + String fullHtml = MarkdownHtmlGenerator.wrapWithBaseHtml(bodyHtml, isDark); + markdownWebView.loadDataWithBaseURL(null, fullHtml, "text/html", "UTF-8", null); + } } diff --git a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt index f7e95e24ad..5912b12fbb 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt +++ b/app/src/main/java/com/amaze/filemanager/ui/activities/texteditor/TextEditorActivityViewModel.kt @@ -66,6 +66,11 @@ class TextEditorActivityViewModel : ViewModel() { var file: EditableFileAbstraction? = null + // ── Markdown preview state ──────────────────────────────────────── + + /** Whether Markdown preview mode is currently enabled. */ + var markdownPreviewEnabled = false + // ── Sliding window state ────────────────────────────────────────── /** Whether the editor is in windowed (read-only) mode for large files. */ diff --git a/app/src/main/res/layout/search.xml b/app/src/main/res/layout/search.xml index 22adfcb697..5abf5b66cc 100644 --- a/app/src/main/res/layout/search.xml +++ b/app/src/main/res/layout/search.xml @@ -43,23 +43,36 @@ - + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:fillViewport="true"> - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/text.xml b/app/src/main/res/menu/text.xml index 993fbbcf1c..09e9391004 100644 --- a/app/src/main/res/menu/text.xml +++ b/app/src/main/res/menu/text.xml @@ -42,4 +42,10 @@ android:title="@string/monofont" android:checkable="true" app:showAsAction="never" /> + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 82cc5020d7..e290da9920 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -479,6 +479,7 @@ メモリ不足。メモリを解放してください トークンを失われた。もう一度サインインしてください。 等幅フォント + Markdown プレビュー ヘッダーを表示する diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7ab5514281..90577f546f 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -479,6 +479,7 @@ Не вистачає пам\'яті, будь ласка звільніть оперативну пам\'ять Token втрачено, будь ласка авторизуйтесь знову Моноширинний шрифт + Попередній перегляд Markdown Відображати заголовки diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index ace1941bcf..4209d72d50 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -485,6 +485,7 @@ 運行記憶體不足,請清除一些背景處理程式 權限遺失,請重新登入 等寬字型 + Markdown 預覽 顯示標頭 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f8d6145e73..0191a051a5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -485,6 +485,7 @@ 運行記憶體不足,請清除一些背景處理程式 權限遺失,請重新登入 等寬字型 + Markdown 預覽 顯示標頭 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11fca688cd..44f1981e73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -566,6 +566,7 @@ Running out of memory, please clear some RAM Token lost, please sign-in again Monospace Font + Markdown Preview Show Headers diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt index e60bb99f01..62785ca900 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt @@ -31,23 +31,25 @@ import java.io.File /** * Unit tests for [FileWindowReader]. - * - * These are pure JVM tests (no Android/Robolectric needed) since FileWindowReader - * only depends on java.io and java.nio classes. */ +@Suppress("StringLiteralDuplication") class FileWindowReaderTest { @get:Rule val tempFolder = TemporaryFolder() private lateinit var testFile: File + /** + * Pre-test setup + */ @Before fun setUp() { testFile = tempFolder.newFile("test.txt") } - // ── Basic reading ──────────────────────────────────────────────── - + /** + * Test reading empty file. + */ @Test fun testReadEmptyFile() { testFile.writeText("") @@ -62,6 +64,9 @@ class FileWindowReaderTest { } } + /** + * Test reading file fitting the window. + */ @Test fun testReadSmallFileFitsInWindow() { val content = "Hello, World!\nSecond line\nThird line\n" @@ -76,6 +81,9 @@ class FileWindowReaderTest { } } + /** + * Test reading a file with a single line that has no newline at the end. + */ @Test fun testReadSingleLineFile() { val content = "No newline at end" @@ -89,6 +97,9 @@ class FileWindowReaderTest { } } + /** + * Test file size is correctly reported. + */ @Test fun testFileSizeCorrect() { val content = "Hello" @@ -99,8 +110,9 @@ class FileWindowReaderTest { } } - // ── Window limiting ────────────────────────────────────────────── - + /** + * Test maxChars limits the output and snaps to line boundaries. + */ @Test fun testMaxCharsLimitsOutput() { // Create content with multiple lines, each larger than 5 chars @@ -117,6 +129,10 @@ class FileWindowReaderTest { } } + /** + * Test window read from the start of the file correctly identifies start of file + * and returns expected content. + */ @Test fun testWindowFromStartOfFile() { val lines = (1..100).map { "Line number $it here\n" } @@ -132,8 +148,10 @@ class FileWindowReaderTest { } } - // ── Mid-file window with line snapping ─────────────────────────── - + /** + * Test window read from the middle of the file snaps to the next line start and does not + * include partial lines at the start. + */ @Test fun testWindowFromMiddleSnapsToLineStart() { val lines = (1..20).map { "Line $it\n" } @@ -153,6 +171,9 @@ class FileWindowReaderTest { } } + /** + * Test when window end falls in the middle of a line, it snaps back to the previous newline + */ @Test fun testWindowEndSnapsToNewline() { // Large content so the window can't contain it all @@ -172,8 +193,10 @@ class FileWindowReaderTest { } } - // ── Window reading near end of file ────────────────────────────── - + /** + * Test reading a window starting near the end of the file where requested maxChars + * exceeds remaining chars + */ @Test fun testWindowNearEndOfFile() { val lines = (1..50).map { "Line $it\n" } @@ -192,6 +215,10 @@ class FileWindowReaderTest { } } + /** + * Test reading a window starting exactly at the end of the file should return empty text and + * indicate end of file. + */ @Test fun testWindowAtExactEndOfFile() { val content = "Hello\nWorld\n" @@ -204,8 +231,9 @@ class FileWindowReaderTest { } } - // ── UTF-8 multi-byte character handling ────────────────────────── - + /** + * Test UTF-8 multibyte boundary handling + */ @Test fun testUtf8MultiByteBoundary() { // Use multibyte UTF-8 characters (emoji = 4 bytes each) @@ -220,6 +248,9 @@ class FileWindowReaderTest { } } + /** + * Test seeking to a byte offset that falls in the middle of a multibyte UTF-8 character + */ @Test fun testUtf8SeekIntoMiddleOfMultibyteChar() { // 2-byte UTF-8 chars: é = C3 A9 (2 bytes) @@ -235,6 +266,9 @@ class FileWindowReaderTest { } } + /** + * Test reading a file with CJK characters + */ @Test fun testCjkCharacters() { // 3-byte UTF-8 chars: Chinese characters @@ -250,8 +284,9 @@ class FileWindowReaderTest { } } - // ── Consecutive window reads (simulating scroll) ───────────────── - + /** + * Test consecutive forward window reads + */ @Test fun testConsecutiveForwardWindowReads() { val lines = (1..200).map { "Line $it\n" } @@ -274,6 +309,9 @@ class FileWindowReaderTest { } } + /** + * Test overlapping windows share content correctly and the overlapping lines are consistent + */ @Test fun testOverlappingWindowsShareContent() { val lines = (1..200).map { "Line $it content here\n" } @@ -298,8 +336,9 @@ class FileWindowReaderTest { } } - // ── Edge cases ─────────────────────────────────────────────────── - + /** + * Test negative offset + */ @Test fun testNegativeOffset() { val content = "Hello\nWorld\n" @@ -313,6 +352,9 @@ class FileWindowReaderTest { } } + /** + * Test offset beyond file size + */ @Test fun testOffsetBeyondFileSize() { val content = "Hello\nWorld\n" @@ -325,6 +367,9 @@ class FileWindowReaderTest { } } + /** + * Test maxChars=0, should return empty text + */ @Test fun testMaxCharsZero() { val content = "Hello\nWorld\n" @@ -337,6 +382,10 @@ class FileWindowReaderTest { } } + /** + * Test file with only newlines, should return correct number of newlines + * and indicate start/end of file + */ @Test fun testFileWithOnlyNewlines() { val content = "\n\n\n\n\n" @@ -350,6 +399,10 @@ class FileWindowReaderTest { } } + /** + * Test very long single line that exceeds maxChars. + * Should return up to maxChars and indicate start of file + */ @Test fun testVeryLongSingleLine() { // Single line with no newlines at all @@ -366,8 +419,9 @@ class FileWindowReaderTest { } } - // ── Close behavior ─────────────────────────────────────────────── - + /** + * Test close reader should release resources and allow for double close without exception + */ @Test fun testCloseReleasesChannel() { val content = "Hello" @@ -378,8 +432,9 @@ class FileWindowReaderTest { reader.close() } - // ── fromFile factory ───────────────────────────────────────────── - + /** + * Test FileWindowReader.fromFile() + */ @Test fun testFromFileFactory() { val content = "Factory test\nLine 2\n" @@ -391,8 +446,9 @@ class FileWindowReaderTest { } } - // ── Byte offset tracking ───────────────────────────────────────── - + /** + * Test byte offset consistency: startByte + text byte length should equal endByte + */ @Test fun testByteOffsetsAreConsistent() { val lines = (1..50).map { "Line $it\n" } @@ -407,6 +463,10 @@ class FileWindowReaderTest { } } + /** + * Test that when reading from a mid-file offset, the returned startByte is at or after + * the requested offset and that the text corresponds to the byte range. + */ @Test fun testByteOffsetsForMidFileRead() { val lines = (1..100).map { "Line $it\n" } diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt index 21ec621007..eb5cbaa360 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/ReadTextFileCallableTest.kt @@ -166,7 +166,7 @@ class ReadTextFileCallableTest { fun testReadBigFileViaFileUriCreatesWindowReader() { val random = Random(456) val letters = ('A'..'Z').toSet() + ('a'..'z').toSet() - val bigContent = List(MAX_FILE_SIZE_CHARS * 2) { letters.random(random) }.joinToString("") + val bigContent = List((MAX_FILE_SIZE_CHARS * 1.05).toInt()) { letters.random(random) }.joinToString("") val file = tempFolder.newFile("bigfile.txt") file.writeText(bigContent) diff --git a/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGeneratorTest.kt b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGeneratorTest.kt new file mode 100644 index 0000000000..2a8fc54ee0 --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/ui/activities/texteditor/MarkdownHtmlGeneratorTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2014-2024 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.amaze.filemanager.ui.activities.texteditor + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [MarkdownHtmlGenerator]. + */ +@Suppress("StringLiteralDuplication") +class MarkdownHtmlGeneratorTest { + /** + * Tests isMarkdownFile + */ + @Test + fun testIsMarkdownFile_md() { + assertTrue(MarkdownHtmlGenerator.isMarkdownFile("README.md")) + assertTrue(MarkdownHtmlGenerator.isMarkdownFile("notes.markdown")) + assertTrue(MarkdownHtmlGenerator.isMarkdownFile("CHANGELOG.MD")) + assertTrue(MarkdownHtmlGenerator.isMarkdownFile("readme.Markdown")) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile("file.txt")) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile("Main.java")) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile("file.md.bak")) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile(null)) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile("")) + assertTrue(MarkdownHtmlGenerator.isMarkdownFile(".md")) + assertFalse(MarkdownHtmlGenerator.isMarkdownFile("README")) + } + + /** + * Tests renderToHtml with styles and formatting + */ + @Test + fun testRenderToHtmlStyles() { + var html = MarkdownHtmlGenerator.renderToHtml("# Hello") + assertTrue("Should contain

", html.contains("

Hello

")) + html = MarkdownHtmlGenerator.renderToHtml("Some text") + assertTrue("Should contain

", html.contains("

Some text

")) + html = MarkdownHtmlGenerator.renderToHtml("**bold**") + assertTrue("Should contain ", html.contains("bold")) + html = MarkdownHtmlGenerator.renderToHtml("*italic*") + assertTrue("Should contain ", html.contains("italic")) + } + + /** + * Tests renderToHtml with lists + */ + @Test + fun testRenderUnorderedList() { + var md = "- item1\n- item2\n- item3" + var html = MarkdownHtmlGenerator.renderToHtml(md) + assertTrue("Should contain
    ", html.contains("
      ")) + assertTrue("Should contain
    • ", html.contains("
    • item1
    • ")) + assertTrue("Should contain
    • ", html.contains("
    • item2
    • ")) + + md = "1. first\n2. second" + html = MarkdownHtmlGenerator.renderToHtml(md) + assertTrue("Should contain
        ", html.contains("
          ")) + assertTrue("Should contain
        1. ", html.contains("
        2. first
        3. ")) + } + + /** + * Tests renderToHtml with links + */ + @Test + fun testRenderLink() { + val html = MarkdownHtmlGenerator.renderToHtml("[click](https://example.com)") + assertTrue("Should contain ", html.contains("click")) + } + + /** + * Test renderToHtml with inline code and code blocks + */ + @Test + fun testRenderInlineCode() { + var html = MarkdownHtmlGenerator.renderToHtml("Use `println()`") + assertTrue("Should contain ", html.contains("println()")) + + val md = "```\nval x = 1\n```" + html = MarkdownHtmlGenerator.renderToHtml(md) + assertTrue("Should contain
          ", html.contains("
          "))
          +        assertTrue("Should contain ", html.contains(""))
          +        assertTrue("Should contain code content", html.contains("val x = 1"))
          +    }
          +
          +    /**
          +     * Test renderToHtml with blockquotes
          +     */
          +    @Test
          +    fun testRenderBlockquote() {
          +        val html = MarkdownHtmlGenerator.renderToHtml("> quote text")
          +        assertTrue("Should contain 
          ", html.contains("
          ")) + assertTrue("Should contain quote text", html.contains("quote text")) + } + + /** + * Test renderToHtml with horizontal rule + */ + @Test + fun testRenderHorizontalRule() { + val html = MarkdownHtmlGenerator.renderToHtml("---") + assertTrue("Should contain
          ", html.contains("H1")) + assertTrue(html.contains("

          H2

          ")) + assertTrue(html.contains("

          H3

          ")) + } + + /** + * Test renderToHtml with image tags + */ + @Test + fun testRenderImage() { + val html = MarkdownHtmlGenerator.renderToHtml("![alt](image.png)") + assertTrue("Should contain ", html.contains("Hello

          ", isDarkTheme = false) + assertTrue("Should start with DOCTYPE", result.contains("")) + result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          Hello

          ", isDarkTheme = false) + assertTrue("Should contain body content", result.contains("

          Hello

          ")) + } + + /** + * Test wrapWithBaseHtml applies correct colors for light and dark themes + */ + @Test + fun testWrapCorrectThemeColors() { + var result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          Test

          ", isDarkTheme = false) + assertTrue("Light bg should be #ffffff", result.contains("#ffffff")) + assertTrue("Light text should be #212121", result.contains("#212121")) + assertTrue("Light link should be #1565c0", result.contains("#1565c0")) + assertTrue("Light code bg should be #f5f5f5", result.contains("#f5f5f5")) + assertTrue("Light border should be #dddddd", result.contains("#dddddd")) + + result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          Test

          ", isDarkTheme = true) + assertTrue("Dark bg should be #1a1a1a", result.contains("#1a1a1a")) + assertTrue("Dark text should be #e0e0e0", result.contains("#e0e0e0")) + assertTrue("Dark link should be #82b1ff", result.contains("#82b1ff")) + assertTrue("Dark code bg should be #2d2d2d", result.contains("#2d2d2d")) + assertTrue("Dark border should be #444444", result.contains("#444444")) + } + + /** + * Test wrapWithBaseHtml contains correct meta tags and CSS rules + */ + @Test + fun testWrapContainsMeta() { + var result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          X

          ", isDarkTheme = false) + assertTrue("Should contain viewport meta", result.contains("viewport")) + assertTrue("Should contain width=device-width", result.contains("width=device-width")) + + result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          X

          ", isDarkTheme = false) + assertTrue("Should contain charset UTF-8", result.contains("charset=\"UTF-8\"")) + + result = MarkdownHtmlGenerator.wrapWithBaseHtml("

          X

          ", isDarkTheme = false) + assertTrue("Should contain style block", result.contains("