diff --git a/app/build.gradle b/app/build.gradle
index 3c380ce487..7762cd5149 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -94,6 +94,8 @@ android {
}
compileOptions {
+ // Flag to enable support for the new language APIs
+ coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
@@ -145,11 +147,16 @@ 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
ksp libs.room.compiler
ksp libs.androidX.annotation
+ coreLibraryDesugaring libs.android.desugaring
//For tests
testImplementation libs.junit//tests the app logic
@@ -257,6 +264,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
new file mode 100644
index 0000000000..78434ae8c7
--- /dev/null
+++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReader.kt
@@ -0,0 +1,243 @@
+/*
+ * 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
+ */
+ @Suppress("LongMethod")
+ 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 !in 1.. 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 =
+ Closeable {
+ 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..2e31e4741c 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
@@ -45,7 +45,7 @@
public class ReadTextFileCallable implements Callable {
- 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;
@@ -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/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/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..eb777d17e0 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,16 +54,19 @@
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;
import android.view.inputmethod.InputMethodManager;
+import android.webkit.WebView;
import android.widget.ScrollView;
import android.widget.Toast;
@@ -84,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;
@@ -99,6 +104,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);
@@ -129,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) {
@@ -173,10 +183,23 @@ 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();
+ initWindowedScrollListener();
+ }
} else {
load(this);
}
initStatusBarResources(findViewById(R.id.textEditorRootView));
+
+ // Observe windowed-mode LiveData for new window content
+ observeWindowContent();
}
@Override
@@ -190,12 +213,19 @@ 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() {
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 +312,19 @@ 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);
+
+ // 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);
}
@@ -336,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;
}
@@ -378,6 +425,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 +665,174 @@ 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;
+
+ // Capture the currently visible text as an anchor before replacing content
+ String anchorText = null;
+ int oldScrollY = scrollView.getScrollY();
+ int viewportHeight = scrollView.getHeight();
+
+ Layout oldLayout = mainTextView.getLayout();
+ if (oldLayout != null && mainTextView.getText() != null) {
+ // Find the line at the middle of the viewport
+ int anchorY = oldScrollY + viewportHeight / 2;
+ int anchorLine = oldLayout.getLineForVertical(anchorY);
+
+ if (anchorLine >= 0 && anchorLine < oldLayout.getLineCount()) {
+ int lineStart = oldLayout.getLineStart(anchorLine);
+ int lineEnd = oldLayout.getLineEnd(anchorLine);
+ if (lineStart < lineEnd && lineEnd <= mainTextView.getText().length()) {
+ // Get a distinctive snippet (up to 80 chars) from this line
+ int snippetEnd = Math.min(lineEnd, lineStart + 80);
+ anchorText =
+ mainTextView.getText().subSequence(lineStart, snippetEnd).toString();
+ }
+ }
+ }
+
+ // Replace text (TextWatcher will fire but windowed-mode guard skips modification
+ // tracking)
+ final String savedAnchorText = anchorText;
+ mainTextView.setText(result.getText());
+
+ // Adjust scroll position for visual continuity
+ mainTextView.post(
+ () -> {
+ Layout newLayout = mainTextView.getLayout();
+ if (newLayout == null) return;
+
+ int targetY;
+
+ // Try to find the anchor text in the new content
+ if (savedAnchorText != null && mainTextView.getText() != null) {
+ String newText = mainTextView.getText().toString();
+ int anchorIndex = newText.indexOf(savedAnchorText);
+
+ if (anchorIndex >= 0) {
+ // Found anchor - restore position so anchor is at middle of viewport
+ int anchorLine = newLayout.getLineForOffset(anchorIndex);
+ int anchorLineTop = newLayout.getLineTop(anchorLine);
+ targetY = Math.max(0, anchorLineTop - viewportHeight / 2);
+ } else {
+ // Anchor not found - use fallback positioning
+ targetY =
+ inferScrollPositionFallback(newLayout, oldScrollY, viewportHeight);
+ }
+ } else {
+ // No anchor - use fallback positioning
+ targetY = inferScrollPositionFallback(newLayout, oldScrollY, viewportHeight);
+ }
+
+ scrollView.scrollTo(0, targetY);
+ invalidateOptionsMenu();
+ });
+ });
+ }
+
+ /**
+ * Fallback method to infer scroll position when anchor text is not found. Uses the old scroll
+ * position to determine if user was scrolling up or down.
+ */
+ private int inferScrollPositionFallback(Layout newLayout, int oldScrollY, int viewportHeight) {
+ // Infer direction from old scroll position
+ if (oldScrollY > viewportHeight / 2) {
+ // Was scrolling down → new content has overlap at the top
+ // Position at roughly 1/3 down to provide smooth downward scrolling
+ int targetLine = newLayout.getLineCount() / 3;
+ return newLayout.getLineTop(targetLine);
+ } else {
+ // Was scrolling up → new content has overlap at the bottom
+ // Position at roughly 2/3 down to provide smooth upward scrolling
+ int targetLine = (newLayout.getLineCount() * 2) / 3;
+ return Math.max(0, newLayout.getLineTop(targetLine) - viewportHeight / 2);
+ }
+ }
+
+ /**
+ * 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);
+ }
+
+ // ── 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 83a57ef534..2f3cf3583c 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,89 @@ class TextEditorActivityViewModel : ViewModel() {
var timer: Timer? = null
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. */
+ 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.
+ *
+ * The method attempts to maintain ~60% overlap between consecutive windows to provide
+ * smooth scrolling. Due to line-boundary snapping, exact overlap cannot be guaranteed,
+ * but this provides better continuity than 50% overlap.
+ */
+ fun loadWindow(direction: Direction) {
+ if (windowLoadJob?.isActive == true) return // debounce
+ val reader = fileWindowReader ?: return
+
+ val windowSize = windowEndByte - windowStartByte
+ // Use 40% shift (60% overlap) for smoother transitions
+ val shiftAmount = (windowSize * 0.4).toLong()
+
+ val targetOffset =
+ when (direction) {
+ Direction.FORWARD -> {
+ // Don't shift if already at end of file
+ if (windowEndByte >= totalFileSize) return
+ windowStartByte + shiftAmount
+ }
+ Direction.BACKWARD -> {
+ // Don't shift if already at start of file
+ if (windowStartByte <= 0L) return
+ maxOf(0L, windowStartByte - shiftAmount)
+ }
+ }
+
+ 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/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
new file mode 100644
index 0000000000..62785ca900
--- /dev/null
+++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/texteditor/read/FileWindowReaderTest.kt
@@ -0,0 +1,487 @@
+/*
+ * 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].
+ */
+@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")
+ }
+
+ /**
+ * Test reading empty file.
+ */
+ @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 reading file fitting the window.
+ */
+ @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 reading a file with a single line that has no newline at the end.
+ */
+ @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 file size is correctly reported.
+ */
+ @Test
+ fun testFileSizeCorrect() {
+ val content = "Hello"
+ testFile.writeText(content)
+ val reader = FileWindowReader.fromFile(testFile)
+ reader.use {
+ assertEquals(content.toByteArray().size.toLong(), it.fileSize)
+ }
+ }
+
+ /**
+ * Test maxChars limits the output and snaps to line boundaries.
+ */
+ @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 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" }
+ 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"))
+ }
+ }
+
+ /**
+ * 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" }
+ 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 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
+ 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())
+ }
+ }
+
+ /**
+ * 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" }
+ 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 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"
+ 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)
+ }
+ }
+
+ /**
+ * Test UTF-8 multibyte boundary 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 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)
+ 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 reading a file with CJK characters
+ */
+ @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)
+ }
+ }
+
+ /**
+ * Test consecutive forward window reads
+ */
+ @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 overlapping windows share content correctly and the overlapping lines are consistent
+ */
+ @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(),
+ )
+ }
+ }
+
+ /**
+ * Test negative offset
+ */
+ @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 offset beyond file size
+ */
+ @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 maxChars=0, should return empty text
+ */
+ @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 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"
+ 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 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
+ 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)
+ }
+ }
+
+ /**
+ * Test close reader should release resources and allow for double close without exception
+ */
+ @Test
+ fun testCloseReleasesChannel() {
+ val content = "Hello"
+ testFile.writeText(content)
+ val reader = FileWindowReader.fromFile(testFile)
+ reader.close()
+ // Double close should not throw
+ reader.close()
+ }
+
+ /**
+ * Test FileWindowReader.fromFile()
+ */
+ @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)
+ }
+ }
+
+ /**
+ * Test byte offset consistency: startByte + text byte length should equal endByte
+ */
+ @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 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" }
+ 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..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
@@ -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 * 1.05).toInt()) { 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/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("