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
        1. ", html.contains("
        2. first
        3. ")) + } + + /** + * Tests renderToHtml with links + */ + @Test + fun testRenderLink() { + val html = MarkdownHtmlGenerator.renderToHtml("[click](https://example.com)") + assertTrue("Should contain ", html.contains("click")) + } + + /** + * Test renderToHtml with inline code and code blocks + */ + @Test + fun testRenderInlineCode() { + var html = MarkdownHtmlGenerator.renderToHtml("Use `println()`") + assertTrue("Should contain ", html.contains("println()")) + + val md = "```\nval x = 1\n```" + html = MarkdownHtmlGenerator.renderToHtml(md) + assertTrue("Should contain
          ", html.contains("
          "))
          +        assertTrue("Should contain ", html.contains(""))
          +        assertTrue("Should contain code content", html.contains("val x = 1"))
          +    }
          +
          +    /**
          +     * Test renderToHtml with blockquotes
          +     */
          +    @Test
          +    fun testRenderBlockquote() {
          +        val html = MarkdownHtmlGenerator.renderToHtml("> quote text")
          +        assertTrue("Should contain 
          ", html.contains("
          ")) + assertTrue("Should contain quote text", html.contains("quote text")) + } + + /** + * Test renderToHtml with horizontal rule + */ + @Test + fun testRenderHorizontalRule() { + val html = MarkdownHtmlGenerator.renderToHtml("---") + assertTrue("Should contain
          ", html.contains("H1")) + assertTrue(html.contains("

          H2

          ")) + assertTrue(html.contains("

          H3

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

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

          Hello

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

          Hello

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

          Test

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

          Test

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

          X

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

          X

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

          X

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