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 - ", html.contains("
- first
"))
+ }
+
+ /**
+ * 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("")
+ 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("