11package io.bashpsk.emptylibs.jetpackui.text
22
3+ import android.content.Context
4+ import android.net.Uri
35import android.util.Log
46import androidx.annotation.IntRange
57import androidx.compose.runtime.Composable
@@ -12,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
1214import androidx.compose.runtime.rememberCoroutineScope
1315import androidx.compose.runtime.retain.retain
1416import androidx.compose.runtime.setValue
17+ import androidx.compose.ui.platform.LocalContext
1518import io.bashpsk.emptylibs.formatter.extension.toMegabytes
1619import io.bashpsk.emptylibs.lrucachemanager.manager.EmptyCacheManager
1720import io.bashpsk.emptylibs.storage.extension.fileLengthOrNull
@@ -44,10 +47,11 @@ fun rememberLazyTextViewerState(
4447 cacheSize : Int = 40
4548): LazyTextViewerState {
4649
50+ val context = LocalContext .current
4751 val coroutineScope = rememberCoroutineScope()
4852
4953 val state = retain(coroutineScope, source) {
50- LazyTextViewerState (coroutineScope = coroutineScope, source = source)
54+ LazyTextViewerState (context = context, coroutineScope = coroutineScope, source = source)
5155 }
5256
5357 LaunchedEffect (cacheSize) {
@@ -75,6 +79,7 @@ fun rememberLazyTextViewerState(
7579 */
7680@Stable
7781class LazyTextViewerState (
82+ private val context : Context ,
7883 internal val coroutineScope : CoroutineScope ,
7984 private val source : TextSource
8085) {
@@ -139,13 +144,14 @@ class LazyTextViewerState(
139144
140145 when (source) {
141146
147+ is TextSource .Empty -> 0
142148 is TextSource .RawString -> source.content?.lines()?.size ? : 0
143149
144- is TextSource .FilePath -> getFileLinesCount(
150+ is TextSource .Path -> getFileLinesCount(
145151 file = source.content?.let { path -> File (path) }
146152 )
147153
148- is TextSource .TextFile -> getFileLinesCount(file = source.content)
154+ is TextSource .URI -> getFileLinesCount(uri = source.content)
149155 }
150156 } catch (exception: Exception ) {
151157
@@ -171,14 +177,16 @@ class LazyTextViewerState(
171177
172178 when (source) {
173179
180+ is TextSource .Empty -> TextContentResult .Content (" " )
181+
174182 is TextSource .RawString -> readLineContent(content = source.content, index = index)
175183
176- is TextSource .FilePath -> readLineContent(
184+ is TextSource .Path -> readLineContent(
177185 file = source.content?.let { path -> File (path) },
178186 index = index
179187 )
180188
181- is TextSource .TextFile -> readLineContent(file = source.content, index = index)
189+ is TextSource .URI -> readLineContent(file = source.content, index = index)
182190 }
183191 } catch (exception: Exception ) {
184192
@@ -220,6 +228,9 @@ class LazyTextViewerState(
220228 *
221229 * @param index The 0-based index of the line to retrieve.
222230 * @return A [TextContentResult] containing either the line text or an error message.
231+ * @throws NullPointerException if the file is null or an I/O error occurs.
232+ * @throws IndexOutOfBoundsException if the index is out of bounds.
233+ * @throws IOException if an I/O error occurs while reading the file.
223234 */
224235 @Throws(NullPointerException ::class , IOException ::class , IndexOutOfBoundsException ::class )
225236 suspend fun readLineContent (
@@ -295,6 +306,88 @@ class LazyTextViewerState(
295306 } ? : throw NullPointerException (" Path is null." )
296307 }
297308
309+ /* *
310+ * Reads the content of a specific line from a [Uri] source.
311+ *
312+ * This function utilizes the [linePointerList] to skip to the nearest pre-calculated
313+ * byte offset and then traverses the stream until the target line is reached.
314+ *
315+ * @param file The [Uri] pointing to the text resource.
316+ * @param index The 0-based index of the line to retrieve.
317+ * @return A [TextContentResult] containing the line text or an error.
318+ * @throws NullPointerException if the [file] URI is null.
319+ * @throws IndexOutOfBoundsException if the index is negative or exceeds [totalLines].
320+ * @throws IOException if the stream cannot be opened or read.
321+ */
322+ suspend fun readLineContent (
323+ file : Uri ? ,
324+ index : Int
325+ ): TextContentResult = withContext(Dispatchers .IO ) {
326+
327+ if (file == null ) throw NullPointerException (" URI is null" )
328+
329+ if (index !in 0 until totalLines) throw IndexOutOfBoundsException (
330+ " Index out of bounds"
331+ )
332+
333+ return @withContext textCacheManager.get(index)?.let { lineText ->
334+
335+ TextContentResult .Content (text = lineText)
336+ } ? : context.contentResolver.openInputStream(file)?.buffered()?.use { inputStream ->
337+
338+ val sparseIndex = index / sparseStep
339+ val linesToSkip = index % sparseStep
340+
341+ inputStream.skip(linePointerList.getOrElse(index = sparseIndex) { 0L })
342+
343+ var linesSkipped = 0
344+
345+ while (linesSkipped < linesToSkip) {
346+
347+ currentCoroutineContext().ensureActive()
348+
349+ val bytes = inputStream.read()
350+
351+ if (bytes == - 1 ) break
352+
353+ when (bytes) {
354+
355+ ' \n ' .code -> linesSkipped++
356+
357+ ' \r ' .code -> {
358+
359+ linesSkipped++
360+ inputStream.mark(1 )
361+ if (inputStream.read() != ' \n ' .code) inputStream.reset()
362+ }
363+ }
364+ }
365+
366+ ByteArrayOutputStream (1024 ).use { outputStream ->
367+
368+ while (currentCoroutineContext().isActive) {
369+
370+ val bytes = inputStream.read()
371+
372+ if (bytes == - 1 || bytes == ' \n ' .code) break
373+
374+ if (bytes == ' \r ' .code) {
375+ inputStream.mark(1 )
376+ if (inputStream.read() != ' \n ' .code) inputStream.reset()
377+ break
378+ }
379+
380+ outputStream.write(bytes)
381+ }
382+
383+ val lineText = outputStream.toString()
384+
385+ textCacheManager.add(index, lineText)
386+ TextContentResult .Content (text = lineText)
387+ }
388+ } ? : throw IOException (" Could not open URI stream" )
389+ }
390+
298391 /* *
299392 * Formats the line number for display.
300393 *
@@ -321,15 +414,79 @@ class LazyTextViewerState(
321414 return @withContext try {
322415
323416 val fileSize = file?.fileLengthOrNull() ? : throw NullPointerException (" Path is null." )
324- val fileSizeInMB = fileSize.toMegabytes()
325417
326- sparseStep = when {
418+ sparseStep = findSparseStep(length = fileSize)
419+
420+ var linesFound = 1
421+ var bytePointer = 0L
422+ var totalReadBytes = 0
423+ var previousWasCR = false
424+ val buffer = ByteArray (DEFAULT_BUFFER_SIZE )
425+ val lineOffsets = persistentListOf(0L ).builder()
426+
427+ file.inputStream().buffered().use { inputStream ->
428+
429+ while (inputStream.read(buffer).also { bytes -> totalReadBytes = bytes } != - 1 ) {
430+
431+ currentCoroutineContext().ensureActive()
432+
433+ (0 until totalReadBytes).forEach { index ->
434+
435+ bytePointer++
436+
437+ previousWasCR = when (buffer[index].toInt() and 0xFF ) {
438+
439+ ' \n ' .code -> if (previousWasCR && (linesFound - 1 ) % sparseStep == 0 ) {
440+ lineOffsets[lineOffsets.size - 1 ] = bytePointer
441+ false
442+ } else {
443+ if (linesFound % sparseStep == 0 ) lineOffsets.add(bytePointer)
444+ linesFound++
445+ false
446+ }
447+
448+ ' \r ' .code -> {
449+ if (linesFound % sparseStep == 0 ) lineOffsets.add(bytePointer)
450+ linesFound++
451+ true
452+ }
453+
454+ else -> false
455+ }
456+ }
457+ }
458+ }
459+
460+ linePointerList = lineOffsets.build()
461+ linesFound
462+ } catch (exception: Exception ) {
327463
328- fileSizeInMB <= 1.0 -> 1
329- fileSizeInMB <= 10.0 -> 100
330- fileSizeInMB <= 50.0 -> 250
331- fileSizeInMB <= 100.0 -> 500
332- else -> 1000
464+ currentCoroutineContext().ensureActive()
465+ Log .e(" LazyTextViewer" , exception.message, exception)
466+ 0
467+ }
468+ }
469+
470+ /* *
471+ * Counts the total number of lines in the content pointed to by the provided [uri] and
472+ * populates [linePointerList] with sparse byte offsets for random access.
473+ *
474+ * This function uses the [context]'s content resolver to open an input stream, calculating
475+ * appropriate sparse steps based on the file size to balance memory usage and seek performance.
476+ * It handles various line terminators (\n, \r, or \r\n).
477+ *
478+ * @param uri The URI of the content to process.
479+ * @return The total number of lines found, or 0 if the URI is null or an error occurs.
480+ */
481+ private suspend fun getFileLinesCount (uri : Uri ? ): Int = withContext(Dispatchers .IO ) {
482+
483+ return @withContext try {
484+
485+ val fileUri = uri ? : throw NullPointerException (" URI is null." )
486+
487+ context.contentResolver.openAssetFileDescriptor(fileUri, " r" ).use { descriptor ->
488+
489+ sparseStep = findSparseStep(length = descriptor?.length ? : 0L )
333490 }
334491
335492 var linesFound = 1
@@ -339,7 +496,7 @@ class LazyTextViewerState(
339496 val buffer = ByteArray (DEFAULT_BUFFER_SIZE )
340497 val lineOffsets = persistentListOf(0L ).builder()
341498
342- file.inputStream() .buffered().use { inputStream ->
499+ context.contentResolver.openInputStream(fileUri)? .buffered()? .use { inputStream ->
343500
344501 while (inputStream.read(buffer).also { bytes -> totalReadBytes = bytes } != - 1 ) {
345502
@@ -382,6 +539,30 @@ class LazyTextViewerState(
382539 }
383540 }
384541
542+ /* *
543+ * Determines the optimal [sparseStep] for storing line offsets based on the file size.
544+ *
545+ * A smaller step provides faster random access but consumes more memory by storing more offsets
546+ * in [linePointerList]. A larger step saves memory but requires more sequential reading when
547+ * jumping to a specific line.
548+ *
549+ * @param length The total length of the file in bytes.
550+ * @return The number of lines to skip between each stored offset.
551+ */
552+ private fun findSparseStep (length : Long ): Int {
553+
554+ val fileSizeInMB = length.toMegabytes()
555+
556+ return when {
557+
558+ fileSizeInMB <= 1.0 -> 1
559+ fileSizeInMB <= 10.0 -> 100
560+ fileSizeInMB <= 50.0 -> 250
561+ fileSizeInMB <= 100.0 -> 500
562+ else -> 1000
563+ }
564+ }
565+
385566 /* *
386567 * Resets the internal state of the viewer to its initial values.
387568 *
0 commit comments