Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
* Copyright (C) 2014-2024 Arpit Khurana <arpitkh96@gmail.com>, Vishal Nehra <vishalmeham2@gmail.com>,
* Emmanuel Messulam<emmanuelbendavid@gmail.com>, Raymond Lai <airwave209gt at gmail.com> 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 <http://www.gnu.org/licenses/>.
*/

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..<fileSize) return offset.coerceIn(0L, fileSize)

val buf = ByteBuffer.allocate(1)
var pos = offset
// Back up at most 3 bytes (max UTF-8 continuation)
val minPos = (offset - 3).coerceAtLeast(0L)

while (pos > minPos) {
synchronized(channel) {
channel.position(pos)
buf.clear()
channel.read(buf)
}
buf.flip()
val b = buf.get().toInt() and 0xFF
// If this byte is NOT a continuation byte, we're at a char boundary
if (b and 0xC0 != 0x80) {
return pos
}
pos--
}
return pos
}

override fun close() {
try {
channel.close()
} catch (_: Exception) {
}
try {
closeable?.close()
} catch (_: Exception) {
}
}

companion object {
/**
* Create a FileWindowReader from a regular File using RandomAccessFile.
*/
fun fromFile(file: File): FileWindowReader {
val raf = RandomAccessFile(file, "r")
return FileWindowReader(
channel = raf.channel,
fileSize = raf.length(),
closeable = raf,
)
}

/**
* Create a FileWindowReader from a content:// URI using ContentResolver.
*/
fun fromContentUri(
contentResolver: ContentResolver,
uri: Uri,
): FileWindowReader {
val pfd =
contentResolver.openFileDescriptor(uri, "r")
?: throw IllegalArgumentException("Cannot open file descriptor for URI: $uri")
val fis = FileInputStream(pfd.fileDescriptor)
val channel = fis.channel
val size = channel.size()
return FileWindowReader(
channel = channel,
fileSize = size,
closeable =
Closeable {
try {
fis.close()
} catch (_: Exception) {
}
try {
pfd.close()
} catch (_: Exception) {
}
},
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

public class ReadTextFileCallable implements Callable<ReturnedValueOnReadFile> {

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;
Expand All @@ -54,6 +54,9 @@ public class ReadTextFileCallable implements Callable<ReturnedValueOnReadFile> {

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,
Expand Down Expand Up @@ -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);
Expand All @@ -94,6 +99,7 @@ public ReturnedValueOnReadFile call()
Objects.requireNonNull(hybridFileParcelable);

File file = hybridFileParcelable.getFile();
resolvedFile = file;
inputStream = loadFile(file);

break;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -141,6 +151,9 @@ class ReadTextFileTask(
.uppercase(Locale.getDefault()),
) { snackbar.dismiss() }
snackbar.show()

// Initialize windowed scroll listener after content is set
textEditorActivity.initWindowedScrollListener()
}
}
}
Loading
Loading