Skip to content

Commit 78af162

Browse files
committed
Implement text load from 'Uri'
1 parent e859733 commit 78af162

3 files changed

Lines changed: 226 additions & 26 deletions

File tree

app/src/main/kotlin/io/bashpsk/emptylibs/LazyTextViewerScreen.kt

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package io.bashpsk.emptylibs
22

3+
import android.net.Uri
34
import android.os.Environment
5+
import androidx.activity.compose.rememberLauncherForActivityResult
6+
import androidx.activity.result.contract.ActivityResultContracts
47
import androidx.compose.foundation.layout.PaddingValues
58
import androidx.compose.foundation.layout.fillMaxSize
69
import androidx.compose.foundation.layout.padding
@@ -18,6 +21,7 @@ import androidx.compose.runtime.getValue
1821
import androidx.compose.runtime.mutableStateOf
1922
import androidx.compose.runtime.remember
2023
import androidx.compose.runtime.rememberCoroutineScope
24+
import androidx.compose.runtime.retain.retain
2125
import androidx.compose.runtime.setValue
2226
import androidx.compose.ui.Modifier
2327
import androidx.compose.ui.unit.dp
@@ -55,10 +59,20 @@ fun LazyTextViewerScreen() {
5559

5660
var oomText by remember { mutableStateOf("") }
5761

62+
var textUri by retain { mutableStateOf<Uri?>(null) }
63+
64+
val filePicker = rememberLauncherForActivityResult(
65+
contract = ActivityResultContracts.GetContent(),
66+
onResult = { resultUri ->
67+
68+
if (resultUri != null) textUri = resultUri
69+
}
70+
)
71+
5872
val textViewerState = rememberLazyTextViewerState(
5973
// source = TextSource.RawString(content = largeText)
60-
source = TextSource.TextFile(content = textFile)
61-
// source = TextSource.FilePath(content = textFile.path)
74+
source = TextSource.Path(content = textFile.path)
75+
// source = TextSource.URI(content = textUri)
6276
// source = TextSource.RawString(content = oomText) /*Simulate Error*/
6377
)
6478

@@ -86,12 +100,14 @@ fun LazyTextViewerScreen() {
86100
topBar = {
87101

88102
TopAppBar(
89-
title = {
90-
91-
Text("Lazy Text Viewer")
92-
},
103+
title = {},
93104
actions = {
94105

106+
Button(onClick = { filePicker.launch("text/plain") }) {
107+
108+
Text(text = "Pick Uri")
109+
}
110+
95111
Button(
96112
onClick = {
97113

jetpack-ui/src/main/kotlin/io/bashpsk/emptylibs/jetpackui/text/LazyTextViewerState.kt

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.bashpsk.emptylibs.jetpackui.text
22

3+
import android.content.Context
4+
import android.net.Uri
35
import android.util.Log
46
import androidx.annotation.IntRange
57
import androidx.compose.runtime.Composable
@@ -12,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf
1214
import androidx.compose.runtime.rememberCoroutineScope
1315
import androidx.compose.runtime.retain.retain
1416
import androidx.compose.runtime.setValue
17+
import androidx.compose.ui.platform.LocalContext
1518
import io.bashpsk.emptylibs.formatter.extension.toMegabytes
1619
import io.bashpsk.emptylibs.lrucachemanager.manager.EmptyCacheManager
1720
import 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
7781
class 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

Comments
 (0)