From 8d4b25df8d8c59cf38227440e8b8aa7a0b872914 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 08:22:30 +0530 Subject: [PATCH 1/6] feat: enhance PDF reading and document editing --- CHANGELOG.md | 24 + app/proguard-rules.pro | 6 + app/src/main/AndroidManifest.xml | 3 +- .../activities/DocumentOpenActivity.kt | 12 +- .../documents/activities/PDFViewerActivity.kt | 50 ++ .../documents/activities/SettingsActivity.kt | 12 + .../activities/TextDocumentActivity.kt | 130 ++++- .../data/DocumentLocationResolver.kt | 28 ++ .../fossify/documents/data/PdfTextSearch.kt | 96 ++++ .../org/fossify/documents/helpers/Config.kt | 21 + .../fossify/documents/models/DocumentKind.kt | 7 + .../ui/screens/DocumentsHomeActions.kt | 40 +- .../ui/screens/DocumentsHomeChrome.kt | 13 +- .../ui/screens/DocumentsSelectionTopBar.kt | 14 - .../documents/ui/screens/PdfDocumentScreen.kt | 445 ++++++------------ .../documents/ui/screens/PdfPageView.kt | 252 ++++++++++ .../ui/screens/PdfReadingControls.kt | 199 ++++++++ .../documents/ui/screens/PdfSearchState.kt | 136 ++++++ .../documents/ui/screens/PdfViewerChrome.kt | 250 ++++++++++ .../documents/ui/screens/SettingsScreen.kt | 26 + .../ui/screens/TextDocumentControls.kt | 78 ++- .../ui/screens/TextDocumentScreen.kt | 121 ++--- .../screens/TextDocumentStatusComponents.kt | 52 +- .../viewmodels/DocumentsViewModel.kt | 3 + .../viewmodels/PdfDocumentViewModel.kt | 63 +++ .../viewmodels/StructuredDocumentViewModel.kt | 6 +- .../viewmodels/TextDocumentViewModel.kt | 171 +++++-- app/src/main/res/values/strings.xml | 15 + .../documents/models/DocumentKindTest.kt | 7 - .../ui/screens/TextDocumentDisplayTest.kt | 3 +- gradle/libs.versions.toml | 6 +- 31 files changed, 1748 insertions(+), 541 deletions(-) create mode 100644 app/src/main/java/org/fossify/documents/data/PdfTextSearch.kt create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/PdfReadingControls.kt create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/PdfSearchState.kt create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt create mode 100644 app/src/main/java/org/fossify/documents/viewmodels/PdfDocumentViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1c5ed..a571c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- PDF text selection and copying +- Option for dark PDF pages +- Option for horizontal PDF scrolling +- PDF text search ([#87]) +- PDF table of contents ([#77]) +- Option to hide floating PDF page numbers ([#79]) +- Reopen to edit button for read-only files ([#71]) +- Support for editing more text formats ([#76]) + +### Fixed + +- Fixed files opened from other apps missing from Recent apps ([#73]) +- Fixed CSV editing for files opened from other apps ([#70]) + ## [1.0.1] - 2026-08-01 ### Changed - Updated translations @@ -17,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release +[#70]: https://github.com/FossifyOrg/Documents/issues/70 +[#71]: https://github.com/FossifyOrg/Documents/issues/71 +[#73]: https://github.com/FossifyOrg/Documents/issues/73 +[#76]: https://github.com/FossifyOrg/Documents/issues/76 +[#77]: https://github.com/FossifyOrg/Documents/issues/77 +[#79]: https://github.com/FossifyOrg/Documents/issues/79 +[#87]: https://github.com/FossifyOrg/Documents/issues/87 + [Unreleased]: https://github.com/FossifyOrg/Documents/compare/1.0.1...HEAD [1.0.1]: https://github.com/FossifyOrg/Documents/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/FossifyOrg/Documents/releases/tag/1.0.0 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2302be0..70d1141 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1 +1,7 @@ -keep class com.shockwave.** { *; } + +# Commons includes optional fingerprint UI that Documents does not use. +-dontwarn android.hardware.fingerprint.FingerprintManager +-dontwarn android.hardware.fingerprint.FingerprintManager$AuthenticationCallback +-dontwarn android.hardware.fingerprint.FingerprintManager$AuthenticationResult +-dontwarn android.hardware.fingerprint.FingerprintManager$CryptoObject diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 292e10f..764082f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,7 +58,6 @@ @@ -75,6 +74,8 @@ + + diff --git a/app/src/main/java/org/fossify/documents/activities/DocumentOpenActivity.kt b/app/src/main/java/org/fossify/documents/activities/DocumentOpenActivity.kt index 7178f61..9bdac55 100644 --- a/app/src/main/java/org/fossify/documents/activities/DocumentOpenActivity.kt +++ b/app/src/main/java/org/fossify/documents/activities/DocumentOpenActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import org.fossify.commons.extensions.getFilenameFromUri -import org.fossify.commons.extensions.getMimeTypeFromUri import org.fossify.commons.extensions.toast import org.fossify.documents.R import org.fossify.documents.models.DocumentKind @@ -18,7 +17,16 @@ class DocumentOpenActivity : Activity() { return } - val destination = when (val kind = DocumentKind.fromName(getFilenameFromUri(uri), getMimeTypeFromUri(uri))) { + val suppliedType = intent.type.orEmpty().substringBefore(';').trim().lowercase() + val mimeType = if (suppliedType.isBlank() || suppliedType == "application/octet-stream" || + suppliedType.endsWith("/*") + ) { + val providerType = contentResolver.getType(uri).orEmpty().substringBefore(';').trim().lowercase() + providerType.takeUnless { it.isBlank() || it == "application/octet-stream" } ?: suppliedType + } else { + suppliedType + } + val destination = when (val kind = DocumentKind.fromName(getFilenameFromUri(uri), mimeType)) { DocumentKind.PDF -> Intent(this, PDFViewerActivity::class.java).apply { data = uri } DocumentKind.TEXT, DocumentKind.MARKDOWN -> TextDocumentActivity.newIntent( diff --git a/app/src/main/java/org/fossify/documents/activities/PDFViewerActivity.kt b/app/src/main/java/org/fossify/documents/activities/PDFViewerActivity.kt index abce6af..aa16e87 100644 --- a/app/src/main/java/org/fossify/documents/activities/PDFViewerActivity.kt +++ b/app/src/main/java/org/fossify/documents/activities/PDFViewerActivity.kt @@ -6,10 +6,15 @@ import android.net.Uri import android.os.Bundle import android.print.PrintAttributes import android.print.PrintManager +import android.provider.DocumentsContract import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -25,8 +30,18 @@ import org.fossify.documents.extensions.config import org.fossify.documents.helpers.PdfDocumentAdapter import org.fossify.documents.ui.screens.PdfDocumentScreen import org.fossify.documents.ui.theme.DocumentsAppThemeSurface +import org.fossify.documents.viewmodels.PdfDocumentViewModel class PDFViewerActivity : BaseComposeActivity() { + private val preferences by lazy { config } + private val viewModel by lazy { ViewModelProvider(this)[PdfDocumentViewModel::class.java] } + private val createCopy = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val destination = result.data?.data + val source = intent.data + if (result.resultCode == RESULT_OK && destination != null && source != null) { + viewModel.saveCopy(source, destination, result.data?.flags ?: 0) + } + } private val pageUpdates = Channel(Channel.CONFLATED) override fun onCreate(savedInstanceState: Bundle?) { @@ -49,9 +64,19 @@ class PDFViewerActivity : BaseComposeActivity() { } } + lifecycleScope.launch { + viewModel.copyMessages.collect { toast(it) } + } + enableEdgeToEdgeSimple() setContent { DocumentsAppThemeSurface { + val nightMode by preferences.pdfDarkPagesFlow + .collectAsStateWithLifecycle(preferences.pdfDarkPages) + val showPageIndicator by preferences.pdfPageIndicatorFlow + .collectAsStateWithLifecycle(preferences.pdfPageIndicator) + val horizontalPaging by preferences.pdfHorizontalPagingFlow + .collectAsStateWithLifecycle(preferences.pdfHorizontalPaging) PdfDocumentScreen( uri = uri, title = title, @@ -65,14 +90,39 @@ class PDFViewerActivity : BaseComposeActivity() { repository.rememberDocument(uri, intent.flags) } }, + onSaveCopy = { requestSaveCopy(uri, title) }, + isCopying = viewModel.isCopying, onPrint = { printPdf(uri, title) }, onOpenWith = { openWith(uri) }, onFullscreenChange = ::setFullscreen, + nightMode = nightMode, + showPageIndicator = showPageIndicator, + horizontalPaging = horizontalPaging, + onNightModeChange = { preferences.pdfDarkPages = it }, + onSettings = { startActivity(Intent(this, SettingsActivity::class.java)) }, ) } } } + private fun requestSaveCopy(uri: Uri, title: String) { + val request = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/pdf" + putExtra(Intent.EXTRA_TITLE, title) + putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + try { + createCopy.launch(request) + } catch (_: ActivityNotFoundException) { + toast(org.fossify.commons.R.string.no_app_found) + } + } + private fun setFullscreen(fullscreen: Boolean) { WindowCompat.getInsetsController(window, window.decorView).apply { systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE diff --git a/app/src/main/java/org/fossify/documents/activities/SettingsActivity.kt b/app/src/main/java/org/fossify/documents/activities/SettingsActivity.kt index e629cd3..16d61bc 100644 --- a/app/src/main/java/org/fossify/documents/activities/SettingsActivity.kt +++ b/app/src/main/java/org/fossify/documents/activities/SettingsActivity.kt @@ -36,6 +36,10 @@ class SettingsActivity : BaseComposeActivity() { .collectAsStateWithLifecycle(preferences.showCheckmarksOnSwitches) val rememberPdfPage by preferences.rememberPdfPageFlow .collectAsStateWithLifecycle(preferences.rememberPdfPage) + val horizontalPdfPaging by preferences.pdfHorizontalPagingFlow + .collectAsStateWithLifecycle(preferences.pdfHorizontalPaging) + val showPdfPageIndicator by preferences.pdfPageIndicatorFlow + .collectAsStateWithLifecycle(preferences.pdfPageIndicator) val showFileLocations by preferences.showFileLocationsFlow .collectAsStateWithLifecycle(preferences.showFileLocations) val displayLanguage = remember { Locale.getDefault().displayLanguage } @@ -51,6 +55,8 @@ class SettingsActivity : BaseComposeActivity() { isUseEnglishChecked = useEnglishFlow, isShowingCheckmarksOnSwitches = showCheckmarksOnSwitches, rememberPdfPage = rememberPdfPage, + horizontalPdfPaging = horizontalPdfPaging, + showPdfPageIndicator = showPdfPageIndicator, showFileLocations = showFileLocations, onUseEnglishPress = { isChecked -> preferences.useEnglish = isChecked @@ -60,6 +66,12 @@ class SettingsActivity : BaseComposeActivity() { onRememberPdfPageChange = { checked -> preferences.rememberPdfPage = checked }, + onHorizontalPdfPagingChange = { checked -> + preferences.pdfHorizontalPaging = checked + }, + onPdfPageIndicatorChange = { checked -> + preferences.pdfPageIndicator = checked + }, onShowFileLocationsChange = { checked -> preferences.showFileLocations = checked }, diff --git a/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt b/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt index 193bb0a..ab9f3eb 100644 --- a/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt +++ b/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt @@ -7,22 +7,26 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.DocumentsContract +import android.webkit.MimeTypeMap import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.fossify.commons.activities.BaseComposeActivity import org.fossify.commons.compose.extensions.enableEdgeToEdgeSimple import org.fossify.commons.extensions.getFilenameFromUri @@ -30,6 +34,7 @@ import org.fossify.commons.extensions.getMimeTypeFromUri import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast import org.fossify.documents.R +import org.fossify.documents.data.DocumentLocationResolver import org.fossify.documents.data.DocumentsRepository import org.fossify.documents.extensions.config import org.fossify.documents.models.DocumentKind @@ -44,15 +49,43 @@ class TextDocumentActivity : BaseComposeActivity() { ViewModelProvider(this)[TextDocumentViewModel::class.java] } + private val createCopy = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + val uri = data?.data + if (result.resultCode == RESULT_OK && uri != null) { + viewModel.saveCopy(uri, data.flags) { toast(R.string.copy_opened) } + } else { + viewModel.cancelSaveCopy() + } + } + + private val openForEditing = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + val uri = data?.data + if (result.resultCode == RESULT_OK && uri != null) { + val kind = DocumentKind.fromName(getFilenameFromUri(uri), getMimeTypeFromUri(uri)) + if (kind in setOf(DocumentKind.TEXT, DocumentKind.MARKDOWN, DocumentKind.CSV)) { + intent.data = uri + lifecycleScope.launch(Dispatchers.IO) { + DocumentsRepository(this@TextDocumentActivity).rememberDocument(uri, data.flags) + } + intent.putExtra(EXTRA_DOCUMENT_KIND, kind.name) + viewModel.load(uri, kind, force = true) + } else { + toast(R.string.unsupported_document_type) + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val uri = intent.data + val uri = savedInstanceState?.getString(STATE_DOCUMENT_URI)?.let(Uri::parse) ?: intent.data if (uri == null) { finish() return } - val kind = intent.getStringExtra(EXTRA_DOCUMENT_KIND) + val kind = (savedInstanceState?.getString(EXTRA_DOCUMENT_KIND) ?: intent.getStringExtra(EXTRA_DOCUMENT_KIND)) ?.let { runCatching { DocumentKind.valueOf(it) }.getOrNull() } ?: DocumentKind.fromName(getFilenameFromUri(uri), getMimeTypeFromUri(uri)) @@ -62,19 +95,17 @@ class TextDocumentActivity : BaseComposeActivity() { } } + viewModel.load(uri, kind) enableEdgeToEdgeSimple() setContent { DocumentsAppThemeSurface { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showDiscardDialog by remember { mutableStateOf(false) } + var showReopenDialog by rememberSaveable { mutableStateOf(false) } var textZoom by remember { mutableFloatStateOf(config.editorTextZoom.coerceDocumentTextZoom()) } - LaunchedEffect(uri, kind) { - viewModel.load(uri, kind) - } - val requestClose = { if (uiState.isDirty) { showDiscardDialog = true @@ -87,10 +118,13 @@ class TextDocumentActivity : BaseComposeActivity() { TextDocumentScreen( uiState = uiState, + editorState = viewModel.editorState, onBack = requestClose, onTextChange = viewModel::onTextChange, onSave = { viewModel.save() }, - onOpenWith = { openWith(uri) }, + onOpenWith = { viewModel.documentUri?.let(::openWith) }, + onSaveCopy = { saveCopy() }, + onOpenForEditing = { showReopenDialog = true }, onPreviewChange = viewModel::setPreviewEnabled, textZoom = textZoom, onTextZoomChange = { requestedZoom -> @@ -104,6 +138,24 @@ class TextDocumentActivity : BaseComposeActivity() { }, ) + if (showReopenDialog) { + AlertDialog( + onDismissRequest = { showReopenDialog = false }, + title = { Text(getString(R.string.reopen_to_edit)) }, + text = { Text(getString(R.string.reopen_to_edit_hint)) }, + confirmButton = { + TextButton(onClick = { showReopenDialog = false; requestWriteAccess() }) { + Text(getString(R.string.open_file)) + } + }, + dismissButton = { + TextButton(onClick = { showReopenDialog = false }) { + Text(getString(org.fossify.commons.R.string.cancel)) + } + }, + ) + } + if (showDiscardDialog) { AlertDialog( onDismissRequest = { showDiscardDialog = false }, @@ -137,6 +189,67 @@ class TextDocumentActivity : BaseComposeActivity() { } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(STATE_DOCUMENT_URI, viewModel.documentUri?.toString()) + outState.putString(EXTRA_DOCUMENT_KIND, viewModel.uiState.value.kind.name) + super.onSaveInstanceState(outState) + } + + private fun saveCopy() { + val state = viewModel.uiState.value + val uri = viewModel.documentUri ?: return + val extension = state.title.substringAfterLast('.', "").lowercase() + val mimeType = if (extension.isEmpty()) { + when (state.kind) { + DocumentKind.MARKDOWN -> "text/markdown" + DocumentKind.CSV -> "text/csv" + else -> "text/plain" + } + } else { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "application/octet-stream" + } + val request = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + putExtra(Intent.EXTRA_TITLE, state.title) + putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + if (!viewModel.prepareSaveCopy()) return + try { + createCopy.launch(request) + } catch (_: ActivityNotFoundException) { + viewModel.cancelSaveCopy() + toast(org.fossify.commons.R.string.no_app_found) + } + } + + private fun requestWriteAccess() { + val uri = viewModel.documentUri ?: return + lifecycleScope.launch { + val location = withContext(Dispatchers.IO) { + DocumentLocationResolver(this@TextDocumentActivity).resolvePickerLocation(uri) + } + val request = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + location?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + try { + openForEditing.launch(request) + } catch (_: ActivityNotFoundException) { + toast(org.fossify.commons.R.string.no_app_found) + } + } + } + private fun openWith(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, getMimeTypeFromUri(uri).ifBlank { "text/*" }) @@ -157,6 +270,7 @@ class TextDocumentActivity : BaseComposeActivity() { } companion object { + private const val STATE_DOCUMENT_URI = "state_document_uri" private const val EXTRA_DOCUMENT_KIND = "extra_document_kind" private const val EXTRA_DOCUMENT_PREPARED = "extra_document_prepared" diff --git a/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt b/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt index f5cbb2f..a0da9c0 100644 --- a/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt +++ b/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt @@ -3,7 +3,9 @@ package org.fossify.documents.data import android.content.Context import android.net.Uri import android.os.Environment +import android.os.storage.StorageManager import android.provider.DocumentsContract +import android.provider.MediaStore import org.fossify.commons.extensions.getRealPathFromURI import org.fossify.documents.R import java.io.File @@ -25,6 +27,32 @@ internal class DocumentLocationResolver( } } + fun resolvePickerLocation(uri: Uri): Uri? { + if (DocumentsContract.isDocumentUri(appContext, uri) || DocumentsContract.isTreeUri(uri)) { + return uri + } + if (uri.authority == MediaStore.AUTHORITY) { + runCatching { MediaStore.getDocumentUri(appContext, uri) }.getOrNull()?.let { return it } + } + val realPath = appContext.getRealPathFromURI(uri)?.takeIf { it.isNotBlank() } ?: return null + return runCatching { + val folder = File(realPath).canonicalFile.parentFile ?: return null + val volume = appContext.getSystemService(StorageManager::class.java) + ?.getStorageVolume(folder) ?: return null + val volumeId = if (volume.isPrimary) "primary" else volume.uuid ?: return null + val root = if (volume.isPrimary) { + Environment.getExternalStorageDirectory().canonicalFile + } else { + File("/storage", volumeId).canonicalFile + } + if (!folder.startsWith(root)) return null + DocumentsContract.buildDocumentUri( + EXTERNAL_STORAGE_AUTHORITY, + "$volumeId:${folder.relativeTo(root).invariantSeparatorsPath}", + ) + }.getOrNull() + } + fun resolveFolder( treeUri: Uri, documentId: String, diff --git a/app/src/main/java/org/fossify/documents/data/PdfTextSearch.kt b/app/src/main/java/org/fossify/documents/data/PdfTextSearch.kt new file mode 100644 index 0000000..60a25c4 --- /dev/null +++ b/app/src/main/java/org/fossify/documents/data/PdfTextSearch.kt @@ -0,0 +1,96 @@ +package org.fossify.documents.data + +import android.content.Context +import android.graphics.RectF +import android.net.Uri +import com.shockwave.pdfium.PdfDocument +import com.shockwave.pdfium.PdfiumCore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.IOException +import kotlin.math.abs + +internal data class PdfTextMatch(val page: Int, val bounds: List) + +internal data class PdfSearchResult( + val matches: List = emptyList(), + val hasText: Boolean = false, +) + +internal class PdfTextSearch(context: Context) { + private val resolver = context.applicationContext.contentResolver + private val core = PdfiumCore(context.applicationContext) + + fun search(uri: Uri, password: String?, query: String) = flow { + val descriptor = resolver.openFileDescriptor(uri, "r") ?: throw IOException() + descriptor.use { + val document = core.newDocument(descriptor, password) + try { + val matches = mutableListOf() + var hasText = false + val count = core.getPageCount(document) + for (page in 0 until count) { + currentCoroutineContext().ensureActive() + core.openPage(document, page) + val text = core.getPageText(document, page) + hasText = hasText || text.isNotBlank() + for (range in findPdfTextMatches(text, query)) { + currentCoroutineContext().ensureActive() + val start = core.getCharIndexFromTextIndex(document, page, range.first) + val end = core.getCharIndexFromTextIndex(document, page, range.last) + if (start >= 0 && end >= start) { + matches += PdfTextMatch(page, matchBounds(document, page, start..end)) + } + } + core.closeTextPage(document, page) + emit(PdfSearchResult(matches.toList(), hasText)) + } + } finally { + core.closeDocument(document) + } + } + }.flowOn(Dispatchers.IO) + + private fun matchBounds(document: PdfDocument, page: Int, range: IntRange): List { + val boxes = range.mapNotNull { index -> + core.getCharBox(document, page, index)?.let { box -> + core.mapRectToDevice(document, page, 0, 0, COORDINATE_SCALE, COORDINATE_SCALE, 0, box) + .apply { + sort() + set( + left / COORDINATE_SCALE, top / COORDINATE_SCALE, + right / COORDINATE_SCALE, bottom / COORDINATE_SCALE + ) + }.takeUnless { it.isEmpty } + } + } + return mergeMatchBounds(boxes) + } +} + +private fun mergeMatchBounds(boxes: List): List = buildList { + boxes.forEach { box -> + val line = lastOrNull() + if (line != null && abs(line.centerY() - box.centerY()) <= maxOf(line.height(), box.height()) / 2f) { + line.union(box) + } else { + add(box) + } + } +} + +private fun findPdfTextMatches(text: String, query: String): List { + if (query.isBlank()) return emptyList() + return buildList { + var start = text.indexOf(query, ignoreCase = true) + while (start >= 0) { + add(start until start + query.length) + start = text.indexOf(query, start + query.length, ignoreCase = true) + } + } +} + +private const val COORDINATE_SCALE = 10000 diff --git a/app/src/main/java/org/fossify/documents/helpers/Config.kt b/app/src/main/java/org/fossify/documents/helpers/Config.kt index eeb8b59..0b6747c 100644 --- a/app/src/main/java/org/fossify/documents/helpers/Config.kt +++ b/app/src/main/java/org/fossify/documents/helpers/Config.kt @@ -11,6 +11,9 @@ class Config(context: Context) : BaseConfig(context) { private const val DOCUMENTS = "documents" private const val DOCUMENT_FOLDERS = "document_folders" private const val EDITOR_TEXT_ZOOM = "editor_text_zoom" + private const val PDF_DARK_PAGES = "pdf_dark_pages" + private const val PDF_HORIZONTAL_PAGING = "pdf_horizontal_paging" + private const val PDF_PAGE_INDICATOR = "pdf_page_indicator" private const val REMEMBER_PDF_PAGE = "remember_pdf_page" private const val SHOW_FILE_LOCATIONS = "show_file_locations" } @@ -37,6 +40,24 @@ class Config(context: Context) : BaseConfig(context) { putFloat(EDITOR_TEXT_ZOOM, value) } + var pdfDarkPages: Boolean + get() = prefs.getBoolean(PDF_DARK_PAGES, false) + set(value) = prefs.edit { putBoolean(PDF_DARK_PAGES, value) } + + val pdfDarkPagesFlow = ::pdfDarkPages.asFlowNonNull(emitOnCollect = true) + + var pdfHorizontalPaging: Boolean + get() = prefs.getBoolean(PDF_HORIZONTAL_PAGING, false) + set(value) = prefs.edit { putBoolean(PDF_HORIZONTAL_PAGING, value) } + + val pdfHorizontalPagingFlow = ::pdfHorizontalPaging.asFlowNonNull(emitOnCollect = true) + + var pdfPageIndicator: Boolean + get() = prefs.getBoolean(PDF_PAGE_INDICATOR, true) + set(value) = prefs.edit { putBoolean(PDF_PAGE_INDICATOR, value) } + + val pdfPageIndicatorFlow = ::pdfPageIndicator.asFlowNonNull(emitOnCollect = true) + var rememberPdfPage: Boolean get() = prefs.getBoolean(REMEMBER_PDF_PAGE, true) set(value) = prefs.edit { diff --git a/app/src/main/java/org/fossify/documents/models/DocumentKind.kt b/app/src/main/java/org/fossify/documents/models/DocumentKind.kt index 3dff122..1eeedcc 100644 --- a/app/src/main/java/org/fossify/documents/models/DocumentKind.kt +++ b/app/src/main/java/org/fossify/documents/models/DocumentKind.kt @@ -43,6 +43,12 @@ enum class DocumentKind { "ics", "vcf", "srt", + "org", + "typ", + "tex", + "adoc", + "asciidoc", + "rst", ) private val extensionKinds = mapOf( "pdf" to PDF, @@ -70,6 +76,7 @@ enum class DocumentKind { "application/xhtml+xml" to HTML, ) private val textApplicationMimeTypes = setOf( + "application/x-tex", "application/json", "application/xml", "application/yaml", diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt index 4098645..61b04dd 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.NoteAdd import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Folder @@ -175,33 +174,29 @@ internal fun ImportAction( modifier = Modifier.widthIn(min = DocumentsMenuMinWidth), offset = DocumentsEndMenuOffset, ) { - ImportMenuItem( - icon = Icons.AutoMirrored.Filled.NoteAdd, - text = stringResource(id = R.string.new_text_file), + DropdownMenuItem( + text = { Text(stringResource(id = R.string.new_text_file)) }, onClick = { expanded = false actions.newTextFile() }, ) - ImportMenuItem( - icon = Icons.AutoMirrored.Filled.NoteAdd, - text = stringResource(id = R.string.new_markdown_file), + DropdownMenuItem( + text = { Text(stringResource(id = R.string.new_markdown_file)) }, onClick = { expanded = false actions.newMarkdownFile() }, ) - ImportMenuItem( - icon = Icons.Filled.Description, - text = stringResource(id = R.string.open_file), + DropdownMenuItem( + text = { Text(stringResource(id = R.string.open_file)) }, onClick = { expanded = false actions.openDocument() }, ) - ImportMenuItem( - icon = Icons.Filled.Folder, - text = stringResource(id = R.string.open_folder), + DropdownMenuItem( + text = { Text(stringResource(id = R.string.open_folder)) }, onClick = { expanded = false actions.openFolder() @@ -223,25 +218,6 @@ internal fun ImportAction( } } -@Composable -private fun ImportMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, -) { - DropdownMenuItem( - text = { Text(text = text) }, - onClick = onClick, - leadingIcon = { - Icon( - imageVector = icon, - contentDescription = null, - tint = SimpleTheme.colorScheme.primary, - ) - }, - ) -} - @Composable internal fun primaryTintAlpha() = if (isDocumentsDarkTheme()) 0.25f else 0.14f diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt index c2f5091..56c19cf 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.fossify.commons.compose.components.SimpleDropDownMenuItem import org.fossify.commons.compose.theme.SimpleTheme import org.fossify.documents.R import org.fossify.documents.models.DocumentFilter @@ -201,23 +200,23 @@ internal fun DocumentsOverflowMenu( modifier = Modifier.widthIn(min = DocumentsMenuMinWidth), offset = DocumentsEndMenuOffset, ) { - SimpleDropDownMenuItem( - text = org.fossify.commons.R.string.settings, + DropdownMenuItem( + text = { Text(stringResource(org.fossify.commons.R.string.settings)) }, onClick = { visible = false actions.openSettings() }, ) - SimpleDropDownMenuItem( - text = org.fossify.commons.R.string.about, + DropdownMenuItem( + text = { Text(stringResource(org.fossify.commons.R.string.about)) }, onClick = { visible = false actions.openAbout() }, ) if (hasDocuments) { - SimpleDropDownMenuItem( - text = R.string.clear_recent_documents, + DropdownMenuItem( + text = { Text(stringResource(R.string.clear_recent_documents)) }, onClick = { visible = false actions.clearRecentDocuments() diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt index 1fbf72d..a0625f3 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt @@ -7,11 +7,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.outlined.RemoveCircleOutline import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.rounded.Close @@ -144,12 +142,6 @@ private fun DocumentsSelectionOverflowMenu( expanded = false actions.onOpenWith(selectedDocument) }, - leadingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = null, - ) - }, ) } if (canRemove) { @@ -159,12 +151,6 @@ private fun DocumentsSelectionOverflowMenu( expanded = false actions.onRemoveSelection() }, - leadingIcon = { - Icon( - imageVector = Icons.Outlined.RemoveCircleOutline, - contentDescription = null, - ) - }, ) } } diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt index 813b563..3037b98 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt @@ -1,5 +1,4 @@ -@file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber") +@file:Suppress("FunctionNaming", "CyclomaticComplexMethod", "LongMethod", "LongParameterList", "MagicNumber") package org.fossify.documents.ui.screens @@ -11,34 +10,15 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.rounded.OpenInNew -import androidx.compose.material.icons.filled.Print -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -52,22 +32,18 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import com.infomaniak.lib.pdfview.PDFView -import com.infomaniak.lib.pdfview.scroll.DefaultScrollHandle -import com.infomaniak.lib.pdfview.util.FitPolicy import com.shockwave.pdfium.PdfPasswordException import kotlinx.coroutines.delay import org.fossify.commons.compose.theme.SimpleTheme import org.fossify.documents.R +import kotlin.time.Duration.Companion.milliseconds @Composable internal fun PdfDocumentScreen( @@ -77,29 +53,48 @@ internal fun PdfDocumentScreen( onBack: () -> Unit, onPageChange: (page: Int, pageCount: Int) -> Unit, onLoad: (pageCount: Int) -> Unit, + onSaveCopy: () -> Unit, + isCopying: Boolean, onPrint: () -> Unit, onOpenWith: () -> Unit, onFullscreenChange: (Boolean) -> Unit, + nightMode: Boolean, + showPageIndicator: Boolean, + horizontalPaging: Boolean, + onNightModeChange: (Boolean) -> Unit, + onSettings: () -> Unit, ) { - var pdfView by remember(uri) { mutableStateOf(null) } + val context = LocalContext.current + val density = LocalDensity.current + var topBarHeight by remember { mutableStateOf(0.dp) } + var navigationHeight by remember { mutableStateOf(0.dp) } + var showOutline by rememberSaveable(uri) { mutableStateOf(false) } + var showGoToPage by rememberSaveable(uri) { mutableStateOf(false) } + var loadAttempt by rememberSaveable(uri) { mutableIntStateOf(0) } + var hasSelection by remember(uri, horizontalPaging, loadAttempt) { mutableStateOf(false) } + var pdfView by remember(uri, horizontalPaging, loadAttempt) { mutableStateOf(null) } + val bookmarks = remember(pdfView) { pdfView?.tableOfContents.orEmpty() } var currentPage by rememberSaveable(uri) { mutableIntStateOf(startPage) } var pageCount by rememberSaveable(uri) { mutableIntStateOf(0) } var controlsVisible by rememberSaveable(uri) { mutableStateOf(true) } var initialAutoHidePending by rememberSaveable(uri) { mutableStateOf(true) } - var hasLoaded by remember(uri) { mutableStateOf(false) } - var isLoading by remember(uri) { mutableStateOf(true) } - var error by remember(uri) { mutableStateOf(null) } + var hasLoaded by remember(uri, horizontalPaging, loadAttempt) { mutableStateOf(false) } + var isLoading by remember(uri, horizontalPaging, loadAttempt) { mutableStateOf(true) } + var error by remember(uri, horizontalPaging, loadAttempt) { mutableStateOf(null) } var password by rememberSaveable(uri) { mutableStateOf(null) } var passwordInput by rememberSaveable(uri) { mutableStateOf("") } var passwordRequested by rememberSaveable(uri) { mutableStateOf(false) } var invalidPassword by rememberSaveable(uri) { mutableStateOf(false) } - var loadAttempt by rememberSaveable(uri) { mutableIntStateOf(0) } - val backgroundColor = SimpleTheme.colorScheme.surfaceVariant.toArgb() - val scrollHandleTextColor = SimpleTheme.colorScheme.onPrimary.toArgb() val currentOnFullscreenChange by rememberUpdatedState(onFullscreenChange) - BackHandler(enabled = !controlsVisible) { - controlsVisible = true + val search = rememberPdfSearchState(uri, password) + val searchKeyboardVisible = search.active && WindowInsets.ime.getBottom(density) > 0 + BackHandler(enabled = !controlsVisible || search.active || hasSelection) { + when { + hasSelection -> pdfView?.clearTextSelection() + search.active -> search.close() + else -> controlsVisible = true + } } LaunchedEffect(controlsVisible) { @@ -116,7 +111,6 @@ internal fun PdfDocumentScreen( DisposableEffect(uri) { onDispose { - pdfView?.recycle() currentOnFullscreenChange(false) } } @@ -124,92 +118,60 @@ internal fun PdfDocumentScreen( Box( modifier = Modifier .fillMaxSize() + .then(if (search.active) Modifier.imePadding() else Modifier) .background(SimpleTheme.colorScheme.surfaceVariant) .cancelInitialAutoHideOnTouch(uri) { initialAutoHidePending = false }, ) { - key(uri, loadAttempt) { - AndroidView( - factory = { context -> - PDFView(context, null).apply { - pdfView = this - setBackgroundColor(backgroundColor) - minZoom = 1f - midZoom = 1.75f - maxZoom = 5f - useBestQuality(true) - - fromUri(uri) - .password(password) - .defaultPage(currentPage.coerceAtLeast(0)) - .enableSwipe(true) - .swipeHorizontal(false) - .enableDoubletap(true) - .enableAnnotationRendering(true) - .enableAntialiasing(true) - .pageFitPolicy(FitPolicy.WIDTH) - .fitEachPage(false) - .autoSpacing(false) - .pageSnap(false) - .pageFling(false) - .pageSeparatorSpacing(8) - .scrollHandle( - DefaultScrollHandle(context).apply { - setTextColor(scrollHandleTextColor) - } - ) - .onTap { - controlsVisible = !controlsVisible - false - } - .onPageChange { page, count -> - currentPage = page - pageCount = count - onPageChange(page, count) - } - .onLoad { count -> - hasLoaded = true - isLoading = false - error = null - pageCount = count - passwordRequested = false - invalidPassword = false - onLoad(count) - } - .onError { throwable -> - isLoading = false - if (throwable is PdfPasswordException) { - invalidPassword = password != null - passwordRequested = true - } else { - error = context.getString(R.string.could_not_open_document) - } - } - .onPageError { _, _ -> - error = context.getString(R.string.could_not_open_document) - } - .load() + key(uri, loadAttempt, horizontalPaging) { + PdfPageView( + uri = uri, + title = title, + currentPage = currentPage, + password = password, + nightMode = nightMode, + showPageIndicator = showPageIndicator, + horizontalPaging = horizontalPaging, + search = search, + modifier = if (search.active) { + Modifier.padding( + top = topBarHeight, + bottom = if (searchKeyboardVisible) 0.dp else navigationHeight, + ) + } else { + Modifier + }, + onReady = { pdfView = it }, + onSelectionChange = { hasSelection = it }, + onTap = { if (!search.active) controlsVisible = !controlsVisible }, + onPageChange = { page, count -> + currentPage = page + pageCount = count + onPageChange(page, count) + }, + onLoad = { count -> + hasLoaded = true + isLoading = false + error = null + pageCount = count + passwordRequested = false + invalidPassword = false + onLoad(count) + }, + onError = { throwable -> + isLoading = false + if (throwable is PdfPasswordException) { + invalidPassword = password != null + passwordRequested = true + } else { + error = context.getString(R.string.could_not_open_document) } }, - modifier = Modifier.fillMaxSize(), ) } - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - - if (error != null) { - Text( - text = error.orEmpty(), - modifier = Modifier - .align(Alignment.Center) - .padding(32.dp), - color = SimpleTheme.colorScheme.error, - style = SimpleTheme.typography.bodyLarge, - ) - } + PdfLoadingStatus(isLoading, error, Modifier.align(Alignment.Center)) AnimatedVisibility( visible = controlsVisible, @@ -217,56 +179,87 @@ internal fun PdfDocumentScreen( exit = fadeOut() + slideOutVertically(targetOffsetY = { -it / 2 }), modifier = Modifier.align(Alignment.TopCenter), ) { - PdfTopBar( - title = title, - onBack = onBack, - onPrint = onPrint, - onOpenWith = onOpenWith, - ) + Column(Modifier.onSizeChanged { topBarHeight = with(density) { it.height.toDp() } }) { + PdfTopBar( + title = title, + onBack = { search.back(onBack) }, + searchActive = search.active, + searchQuery = search.query, + searchResult = search.result, + searchIndex = search.index, + onSearch = { + pdfView?.clearTextSelection() + search.active = true + }, + onSearchQueryChange = search::updateQuery, + onChangeMatch = search::changeMatch, + onOutline = { showOutline = true }, + hasOutline = bookmarks.isNotEmpty(), + onPrint = onPrint, + menu = { + PdfReadingMenu( + nightMode = nightMode, + onNightModeChange = onNightModeChange, + onSettings = onSettings, + onGoToPage = { showGoToPage = true }, + onSaveCopy = onSaveCopy, + isCopying = isCopying, + onPrint = onPrint, + showPrint = bookmarks.isNotEmpty(), + onOpenWith = onOpenWith, + isLoaded = hasLoaded, + ) + }, + isLoaded = hasLoaded, + ) + if (isCopying) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + PdfSearchStatus(search) + } } AnimatedVisibility( - visible = controlsVisible && pageCount > 0, + visible = controlsVisible && pageCount > 0 && !searchKeyboardVisible, enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }), exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }), - modifier = Modifier.align(Alignment.BottomCenter), + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { navigationHeight = with(density) { it.height.toDp() } }, ) { - PdfControls( + PdfNavigationControls( + view = pdfView, currentPage = currentPage, pageCount = pageCount, - onPreviousPage = { - pdfView?.jumpTo((currentPage - 1).coerceAtLeast(0), true) - }, - onNextPage = { - pdfView?.jumpTo((currentPage + 1).coerceAtMost(pageCount - 1), true) - }, - onZoomOut = { - pdfView?.let { view -> - view.zoomWithAnimation((view.zoom - 0.5f).coerceAtLeast(view.minZoom)) - } - }, - onZoomIn = { - pdfView?.let { view -> - view.zoomWithAnimation((view.zoom + 0.5f).coerceAtMost(view.maxZoom)) - } - }, + onGoToPage = { showGoToPage = true }, ) } } + if (showOutline && bookmarks.isNotEmpty()) { + PdfOutlineSheet( + bookmarks = bookmarks, + onGo = { page -> pdfView?.goToPage(page); showOutline = false }, + onDismiss = { showOutline = false }, + ) + } + if (showGoToPage) { + PdfGoToPageDialog( + currentPage = currentPage, + pageCount = pageCount, + onGo = { page -> pdfView?.goToPage(page); showGoToPage = false }, + onDismiss = { showGoToPage = false }, + ) + } if (passwordRequested) { PasswordDialog( password = passwordInput, invalidPassword = invalidPassword, onPasswordChange = { passwordInput = it }, onConfirm = { - if (passwordInput.isNotBlank()) { - password = passwordInput - passwordRequested = false - isLoading = true - error = null - loadAttempt++ - } + password = passwordInput + passwordRequested = false + isLoading = true + error = null + loadAttempt++ }, onDismiss = onBack, ) @@ -284,7 +277,7 @@ private fun AutoHideInitialPdfControls( val currentOnAutoHide by rememberUpdatedState(onAutoHide) LaunchedEffect(hasLoaded, pending) { if (hasLoaded && pending) { - delay(INITIAL_CONTROLS_HIDE_DELAY_MS) + delay(INITIAL_CONTROLS_HIDE_DELAY_MS.milliseconds) currentOnAutoHide() } } @@ -302,149 +295,3 @@ private fun Modifier.cancelInitialAutoHideOnTouch(uri: Uri, onTouch: () -> Unit) } } } - -@Composable -private fun PdfTopBar( - title: String, - onBack: () -> Unit, - onPrint: () -> Unit, - onOpenWith: () -> Unit, -) { - TopAppBar( - title = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = SimpleTheme.typography.titleLarge, - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = org.fossify.commons.R.string.back), - ) - } - }, - actions = { - IconButton(onClick = onPrint) { - Icon( - imageVector = Icons.Filled.Print, - contentDescription = stringResource(id = org.fossify.commons.R.string.print), - ) - } - IconButton(onClick = onOpenWith) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = stringResource(id = org.fossify.commons.R.string.open_with), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = SimpleTheme.colorScheme.surface, - ), - ) -} - -@Composable -private fun PdfControls( - currentPage: Int, - pageCount: Int, - onPreviousPage: () -> Unit, - onNextPage: () -> Unit, - onZoomOut: () -> Unit, - onZoomIn: () -> Unit, -) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = SimpleTheme.colorScheme.surface, - contentColor = SimpleTheme.colorScheme.onSurface, - tonalElevation = 3.dp, - shadowElevation = 6.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .height(64.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - IconButton(onClick = onPreviousPage, enabled = currentPage > 0) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = stringResource(id = R.string.previous_page), - ) - } - Text( - text = stringResource(id = R.string.page_count_value, currentPage + 1, pageCount), - style = SimpleTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - ) - IconButton(onClick = onNextPage, enabled = currentPage < pageCount - 1) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = stringResource(id = R.string.next_page), - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - IconButton(onClick = onZoomOut) { - Icon( - imageVector = Icons.Filled.ZoomOut, - contentDescription = stringResource(id = R.string.zoom_out), - ) - } - IconButton(onClick = onZoomIn) { - Icon( - imageVector = Icons.Filled.ZoomIn, - contentDescription = stringResource(id = R.string.zoom_in), - ) - } - } - } -} - -@Composable -private fun PasswordDialog( - password: String, - invalidPassword: Boolean, - onPasswordChange: (String) -> Unit, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(id = org.fossify.commons.R.string.enter_password)) - }, - text = { - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - label = { Text(text = stringResource(id = org.fossify.commons.R.string.password)) }, - supportingText = if (invalidPassword) { - { Text(text = stringResource(id = org.fossify.commons.R.string.invalid_password)) } - } else { - null - }, - isError = invalidPassword, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - ) - }, - confirmButton = { - TextButton(onClick = onConfirm, enabled = password.isNotBlank()) { - Text(text = stringResource(id = org.fossify.commons.R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = stringResource(id = org.fossify.commons.R.string.cancel)) - } - }, - ) -} diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt new file mode 100644 index 0000000..336e117 --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt @@ -0,0 +1,252 @@ +package org.fossify.documents.ui.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.infomaniak.lib.pdfview.PDFView +import com.infomaniak.lib.pdfview.link.DefaultLinkHandler +import com.infomaniak.lib.pdfview.model.LinkTapEvent +import com.infomaniak.lib.pdfview.scroll.DefaultScrollHandle +import com.infomaniak.lib.pdfview.util.FitPolicy +import org.fossify.commons.compose.theme.SimpleTheme +import org.fossify.commons.compose.theme.isSurfaceNotLitWell +import org.fossify.documents.data.PdfTextMatch + +@Composable +internal fun PdfPageView( + uri: Uri, + title: String, + currentPage: Int, + password: String?, + nightMode: Boolean, + showPageIndicator: Boolean, + horizontalPaging: Boolean, + search: PdfSearchState, + onReady: (PDFView) -> Unit, + onSelectionChange: (Boolean) -> Unit, + onTap: () -> Unit, + onPageChange: (Int, Int) -> Unit, + onLoad: (Int) -> Unit, + onError: (Throwable) -> Unit, + modifier: Modifier = Modifier, +) { + var view by remember { mutableStateOf(null) } + var viewSize by remember { mutableStateOf(IntSize.Zero) } + var pageIndicator by remember { mutableStateOf(null) } + val currentShowPageIndicator by rememberUpdatedState(showPageIndicator) + val currentSelectedMatch by rememberUpdatedState(search.selectedMatch) + val matchesByPage by rememberUpdatedState(remember(search.result) { search.result.matches.groupBy { it.page } }) + val backgroundColor = SimpleTheme.colorScheme.surfaceVariant.toArgb() + val scrollHandleTextColor = SimpleTheme.colorScheme.onSurfaceVariant.toArgb() + val colors = SimpleTheme.colorScheme + val highlightColor = pdfHighlightColor(nightMode) + val selectionColor = highlightColor.toArgb() + val selectionHighlight = highlightColor.copy(alpha = 0.3f).toArgb() + val matchColor by rememberUpdatedState(highlightColor.copy(alpha = MATCH_HIGHLIGHT_ALPHA).toArgb()) + val currentMatchColor by rememberUpdatedState(highlightColor.copy(alpha = CURRENT_MATCH_HIGHLIGHT_ALPHA).toArgb()) + LaunchedEffect(nightMode, colors, view) { + view?.apply { + setNightMode(nightMode) + setSelectionHandleColor(selectionColor) + setSelectionHighlightColor(selectionHighlight) + setSelectionPopupBackgroundColor(colors.surfaceContainerHigh.toArgb()) + setSelectionPopupTextColor(colors.onSurface.toArgb()) + invalidate() + } + } + LaunchedEffect(showPageIndicator) { + if (!showPageIndicator) pageIndicator?.hide() + } + LaunchedEffect(view) { view?.performPageSnap() } + LaunchedEffect(search.selectedMatch, search.navigationRequest, view, viewSize) { + search.selectedMatch?.let { view?.showSearchMatch(it) } + } + LaunchedEffect(search.result, search.index) { view?.invalidate() } + AndroidView( + factory = { context -> + ResizablePdfView(context).apply { + val matchPaint = Paint(Paint.ANTI_ALIAS_FLAG) + var maxPageHeight = 0f + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + maxPageHeight = maxHorizontalPageHeight() + } + setBackgroundColor(backgroundColor) + minZoom = 1f + midZoom = PDF_MID_ZOOM + maxZoom = PDF_MAX_ZOOM + useBestQuality(true) + + fromUri(uri) + .password(password) + .defaultPage(currentPage.coerceAtLeast(0)) + .enableSwipe(true) + .enableDoubletap(true) + .enableTextSelection(true) + .selectionHandleColor(selectionColor) + .selectionHighlightColor(selectionHighlight) + .selectionPopupBackgroundColor(colors.surfaceContainerHigh.toArgb()) + .selectionPopupTextColor(colors.onSurface.toArgb()) + .nightMode(nightMode) + .selectionPopupText(context.getString(android.R.string.copy)) + .onSelectionChange(onSelectionChange) + .onSelectionAction { text -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(title, text)) + clearTextSelection() + } + .onDrawAll { canvas, width, height, page -> + val left = if (isSwipeVertical) (this.width * zoom - width) / 2f else 0f + val top = if (isSwipeVertical) 0f else (maxPageHeight * zoom - height) / 2f + matchesByPage[page].orEmpty().forEach { match -> + matchPaint.color = if (match === currentSelectedMatch) currentMatchColor else matchColor + match.bounds.forEach { rect -> + canvas.drawRect( + left + rect.left * width, top + rect.top * height, + left + rect.right * width, top + rect.bottom * height, matchPaint, + ) + } + } + } + .linkHandler { handleReadingLink(it) } + .enableAnnotationRendering(true) + .enableAntialiasing(true) + .readingMode(horizontalPaging) + .scrollHandle( + object : DefaultScrollHandle(context) { + override fun show() { + if (currentShowPageIndicator) super.show() + } + }.apply { + setPageHandleBackground(context.scrollHandleBackground(horizontalPaging, backgroundColor)) + setTextColor(scrollHandleTextColor) + pageIndicator = this + } + ) + .onTap { onTap(); false } + .onPageChange(onPageChange) + .onLoad { count -> + if (horizontalPaging) resetZoom() + maxPageHeight = maxHorizontalPageHeight() + view = this + onReady(this) + onLoad(count) + } + .onError(onError) + .onPageError { _, error -> onError(error) } + .load() + } + }, + modifier = modifier + .fillMaxSize() + .onSizeChanged { viewSize = it }, + onRelease = { view -> view.recycle() }, + ) +} + +private fun PDFView.maxHorizontalPageHeight(): Float { + if (isSwipeVertical) return 0f + return (0 until pageCount).maxOfOrNull { getPageSize(it).height } ?: 0f +} + +private class ResizablePdfView(context: Context) : PDFView(context, null) { + private var lastSize = IntSize.Zero + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + // PDFView divides by its previous page size when restoring the scroll position. + if (w == 0 || h == 0) { + stopFling() + return + } + super.onSizeChanged(w, h, lastSize.width, lastSize.height) + lastSize = IntSize(w, h) + } +} + +@Composable +private fun pdfHighlightColor(nightMode: Boolean) = if (nightMode == isSurfaceNotLitWell()) { + SimpleTheme.colorScheme.primary +} else { + SimpleTheme.colorScheme.inversePrimary +} + +private fun Context.scrollHandleBackground(horizontalPaging: Boolean, color: Int): Drawable? { + val resource = if (horizontalPaging) { + com.infomaniak.lib.pdfview.R.drawable.default_scroll_handle_bottom + } else { + com.infomaniak.lib.pdfview.R.drawable.default_scroll_handle_right + } + return ContextCompat.getDrawable(this, resource)?.mutate()?.apply { setTint(color) } +} + +private fun PDFView.Configurator.readingMode(horizontalPaging: Boolean): PDFView.Configurator { + return swipeHorizontal(horizontalPaging) + .pageFitPolicy(if (horizontalPaging) FitPolicy.BOTH else FitPolicy.WIDTH) + .fitEachPage(false) + .autoSpacing(horizontalPaging) + .pageSnap(horizontalPaging) + .pageFling(horizontalPaging) + .pageSeparatorSpacing(if (horizontalPaging) 0 else PDF_PAGE_SPACING_DP) + .apply { if (horizontalPaging) zoom(1f, PDF_MID_ZOOM, PDF_MAX_ZOOM) } +} + +private const val PDF_MID_ZOOM = 1.75f +private const val PDF_MAX_ZOOM = 5f +private const val PDF_PAGE_SPACING_DP = 8 +private const val MATCH_HIGHLIGHT_ALPHA = 0.18f +private const val CURRENT_MATCH_HIGHLIGHT_ALPHA = 0.4f + +private fun PDFView.showSearchMatch(match: PdfTextMatch) { + if (width == 0 || height == 0) return + stopFling() + jumpTo(match.page) + val bounds = match.bounds.firstOrNull() ?: return + if (!isSwipeVertical && zoom == minZoom) { + performPageSnap() + return + } + val size = getPageSize(match.page) + val matchX: Float + val matchY: Float + if (isSwipeVertical) { + val pageTop = (0 until match.page).sumOf { getPageSize(it).height.toDouble() + pageSeparatorSpacing }.toFloat() + matchY = (startSpacing + pageTop + bounds.centerY() * size.height) * zoom + matchX = ((width - size.width) / 2f + bounds.centerX() * size.width) * zoom + } else { + val pageLeft = match.page * (width + pageSeparatorSpacing) + (width - size.width) / 2f + val maxPageHeight = (0 until pageCount).maxOf { getPageSize(it).height } + matchX = (pageLeft + bounds.centerX() * size.width) * zoom + matchY = ((maxPageHeight - size.height) / 2f + bounds.centerY() * size.height) * zoom + } + moveTo(width / 2f - matchX, height / 2f - matchY) + loadPages() +} + +internal fun PDFView.goToPage(page: Int, withAnimation: Boolean = false) { + stopFling() + jumpTo(page, withAnimation && isSwipeVertical) + if (!isSwipeVertical) performPageSnap() +} + +private fun PDFView.handleReadingLink(event: LinkTapEvent) { + DefaultLinkHandler(this).handleLinkEvent(event) + if (!isSwipeVertical && event.link.uri.isNullOrEmpty() && event.link.destPageIdx != null) { + performPageSnap() + } +} diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfReadingControls.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfReadingControls.kt new file mode 100644 index 0000000..243f12f --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfReadingControls.kt @@ -0,0 +1,199 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.fossify.documents.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.shockwave.pdfium.PdfDocument +import org.fossify.commons.compose.theme.SimpleTheme +import org.fossify.documents.R + +@Composable +internal fun PdfReadingMenu( + nightMode: Boolean, + onNightModeChange: (Boolean) -> Unit, + onSettings: () -> Unit, + onGoToPage: () -> Unit, + onSaveCopy: () -> Unit, + isCopying: Boolean, + onPrint: () -> Unit, + showPrint: Boolean, + onOpenWith: () -> Unit, + isLoaded: Boolean, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Filled.MoreVert, stringResource(org.fossify.commons.R.string.more_options)) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.widthIn(min = DocumentsMenuMinWidth), + offset = DocumentsEndMenuOffset, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.go_to_page)) }, + enabled = isLoaded, + onClick = { expanded = false; onGoToPage() }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.pdf_dark_pages)) }, + modifier = Modifier.semantics { + role = Role.Checkbox + toggleableState = ToggleableState(nightMode) + }, + trailingIcon = { Checkbox(checked = nightMode, onCheckedChange = null) }, + onClick = { expanded = false; onNightModeChange(!nightMode) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.save_a_copy)) }, + enabled = isLoaded && !isCopying, + onClick = { expanded = false; onSaveCopy() }, + ) + if (showPrint) { + DropdownMenuItem( + text = { Text(stringResource(org.fossify.commons.R.string.print)) }, + enabled = isLoaded, + onClick = { expanded = false; onPrint() }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(org.fossify.commons.R.string.open_with)) }, + onClick = { expanded = false; onOpenWith() }, + ) + DropdownMenuItem( + text = { Text(stringResource(org.fossify.commons.R.string.settings)) }, + onClick = { expanded = false; onSettings() }, + ) + } + } +} + +@Composable +internal fun PdfGoToPageDialog(currentPage: Int, pageCount: Int, onGo: (Int) -> Unit, onDismiss: () -> Unit) { + var input by rememberSaveable(stateSaver = TextFieldValue.Saver) { + val value = (currentPage + 1).toString() + mutableStateOf(TextFieldValue(value, TextRange(0, value.length))) + } + val page = input.text.toIntOrNull() + val valid = page != null && page in 1..pageCount + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.go_to_page)) }, + text = { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + singleLine = true, + label = { Text(stringResource(R.string.pdf_page_range, pageCount)) }, + isError = input.text.isNotEmpty() && !valid, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { if (valid) onGo(page - 1) }), + ) + }, + confirmButton = { + TextButton(onClick = { if (valid) onGo(page - 1) }, enabled = valid) { + Text(stringResource(org.fossify.commons.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(org.fossify.commons.R.string.cancel)) } + }, + ) +} + +@Composable +internal fun PdfOutlineSheet(bookmarks: List, onGo: (Int) -> Unit, onDismiss: () -> Unit) { + val entries = remember(bookmarks) { flattenPdfOutline(bookmarks) } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column { + Text( + stringResource(R.string.pdf_contents), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + style = SimpleTheme.typography.titleLarge, + ) + LazyColumn { + items(entries) { entry -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = entry.page >= 0) { onGo(entry.page) } + .padding(start = 24.dp + 16.dp * entry.depth, end = 24.dp, top = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(entry.title, modifier = Modifier.weight(1f), style = SimpleTheme.typography.bodyLarge) + if (entry.page >= 0) { + Text((entry.page + 1).toString(), color = SimpleTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } +} + +private data class PdfOutlineEntry(val title: String, val page: Int, val depth: Int) + +private fun flattenPdfOutline( + bookmarks: List, + depth: Int = 0, +): List = buildList { + bookmarks.forEach { bookmark -> + add(PdfOutlineEntry(bookmark.title.orEmpty(), bookmark.pageIdx.toInt(), depth)) + addAll(flattenPdfOutline(bookmark.children, depth + 1)) + } +} diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfSearchState.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfSearchState.kt new file mode 100644 index 0000000..333b5eb --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfSearchState.kt @@ -0,0 +1,136 @@ +package org.fossify.documents.ui.screens + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.fossify.documents.R +import org.fossify.documents.data.PdfSearchResult +import org.fossify.documents.data.PdfTextSearch +import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds + +@Stable +internal class PdfSearchState(active: Boolean = false, query: String = "", index: Int = 0) { + var active by mutableStateOf(active) + var query by mutableStateOf(query) + private set + var result by mutableStateOf(PdfSearchResult()) + private set + var index by mutableIntStateOf(index) + private set + var navigationRequest by mutableIntStateOf(0) + private set + var inProgress by mutableStateOf(false) + private set + var failed by mutableStateOf(false) + private set + var searchAttempt by mutableIntStateOf(0) + private set + val selectedMatch get() = result.matches.getOrNull(index) + + fun updateQuery(value: String) { + query = value + index = 0 + } + + fun retry() { + searchAttempt++ + } + + fun close() { + active = false + updateQuery("") + } + + fun back(onClose: () -> Unit) { + if (active) close() else onClose() + } + + fun changeMatch(direction: Int) { + if (index in result.matches.indices) { + index = (index + direction).mod(result.matches.size) + navigationRequest++ + } + } + + suspend fun search(context: Context, uri: Uri, password: String?) { + result = PdfSearchResult() + failed = false + inProgress = active && query.isNotBlank() + if (!inProgress) return + delay(SEARCH_DELAY_MS.milliseconds) + try { + PdfTextSearch(context).search(uri, password, query).collect { result = it } + } catch (error: CancellationException) { + throw error + } catch (_: IOException) { + failed = true + } catch (_: SecurityException) { + failed = true + } catch (_: IllegalStateException) { + failed = true + } + inProgress = false + } + + companion object { + val Saver = listSaver( + save = { listOf(it.active, it.query, it.index) }, + restore = { PdfSearchState(it[0] as Boolean, it[1] as String, it[2] as Int) }, + ) + } +} + +@Composable +internal fun rememberPdfSearchState(uri: Uri, password: String?): PdfSearchState { + val context = LocalContext.current + val state = rememberSaveable(uri, saver = PdfSearchState.Saver) { PdfSearchState() } + LaunchedEffect(uri, password, state.query, state.active, state.searchAttempt) { + state.search(context, uri, password) + } + return state +} + +@Composable +internal fun PdfSearchStatus(search: PdfSearchState) { + if (search.inProgress) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else if (search.active && search.query.isNotBlank()) { + val message = when { + search.failed -> R.string.pdf_search_failed + !search.result.hasText -> R.string.pdf_no_text + search.result.matches.isEmpty() -> R.string.no_results_found + else -> null + } + message?.let { + StatusStrip( + text = stringResource(it), + isError = search.failed, + action = if (search.failed) { + { TextButton(onClick = search::retry) { Text(stringResource(R.string.retry)) } } + } else { + null + }, + ) + } + } +} + +private const val SEARCH_DELAY_MS = 250L diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt new file mode 100644 index 0000000..3e0fa9c --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt @@ -0,0 +1,250 @@ +@file:OptIn(ExperimentalMaterial3Api::class) +@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber") + +package org.fossify.documents.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Print +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.infomaniak.lib.pdfview.PDFView +import org.fossify.commons.compose.theme.SimpleTheme +import org.fossify.documents.R +import org.fossify.documents.data.PdfSearchResult + +@Composable +internal fun PdfTopBar( + title: String, + onBack: () -> Unit, + searchActive: Boolean, + searchQuery: String, + searchResult: PdfSearchResult, + searchIndex: Int, + onSearch: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onChangeMatch: (Int) -> Unit, + onOutline: () -> Unit, + hasOutline: Boolean, + onPrint: () -> Unit, + menu: @Composable () -> Unit, + isLoaded: Boolean, +) { + TopAppBar( + title = { + if (searchActive) { + TextDocumentSearchField( + query = searchQuery, + currentMatchNumber = if (searchIndex in searchResult.matches.indices) searchIndex + 1 else 0, + matchCount = searchResult.matches.size, + onQueryChange = onSearchQueryChange, + onNextMatch = { onChangeMatch(1) }, + ) + } else { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = SimpleTheme.typography.titleLarge, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(org.fossify.commons.R.string.back)) + } + }, + actions = { + if (searchActive) { + IconButton(onClick = { onChangeMatch(-1) }, enabled = searchIndex in searchResult.matches.indices) { + Icon(Icons.Filled.KeyboardArrowUp, stringResource(R.string.previous_match)) + } + IconButton(onClick = { onChangeMatch(1) }, enabled = searchIndex in searchResult.matches.indices) { + Icon(Icons.Filled.KeyboardArrowDown, stringResource(R.string.next_match)) + } + } else { + IconButton(onClick = onSearch, enabled = isLoaded) { + Icon(Icons.Filled.Search, stringResource(R.string.search_text_in_document)) + } + if (hasOutline) { + IconButton(onClick = onOutline, enabled = isLoaded) { + Icon(Icons.AutoMirrored.Filled.List, stringResource(R.string.pdf_contents)) + } + } else { + IconButton(onClick = onPrint, enabled = isLoaded) { + Icon(Icons.Filled.Print, stringResource(org.fossify.commons.R.string.print)) + } + } + menu() + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = SimpleTheme.colorScheme.surface), + ) +} + +@Composable +internal fun PdfControls( + currentPage: Int, + pageCount: Int, + onGoToPage: () -> Unit, + onPreviousPage: () -> Unit, + onNextPage: () -> Unit, + onZoomOut: () -> Unit, + onZoomIn: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = SimpleTheme.colorScheme.surface, + contentColor = SimpleTheme.colorScheme.onSurface, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + IconButton(onClick = onPreviousPage, enabled = currentPage > 0) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(id = R.string.previous_page), + ) + } + val pageDescription = stringResource(R.string.pdf_page_navigation, currentPage + 1, pageCount) + TextButton(onClick = onGoToPage) { + Text( + modifier = Modifier.clearAndSetSemantics { contentDescription = pageDescription }, + text = stringResource(id = R.string.page_count_value, currentPage + 1, pageCount), + style = SimpleTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + ) + } + IconButton(onClick = onNextPage, enabled = currentPage < pageCount - 1) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(id = R.string.next_page), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + IconButton(onClick = onZoomOut) { + Icon( + imageVector = Icons.Filled.ZoomOut, + contentDescription = stringResource(id = R.string.zoom_out), + ) + } + IconButton(onClick = onZoomIn) { + Icon( + imageVector = Icons.Filled.ZoomIn, + contentDescription = stringResource(id = R.string.zoom_in), + ) + } + } + } +} + +@Composable +internal fun PasswordDialog( + password: String, + invalidPassword: Boolean, + onPasswordChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(id = org.fossify.commons.R.string.enter_password)) + }, + text = { + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(text = stringResource(id = org.fossify.commons.R.string.password)) }, + supportingText = if (invalidPassword) { + { Text(text = stringResource(id = org.fossify.commons.R.string.invalid_password)) } + } else { + null + }, + isError = invalidPassword, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = password.isNotBlank()) { + Text(text = stringResource(id = org.fossify.commons.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = org.fossify.commons.R.string.cancel)) + } + }, + ) +} + +@Composable +internal fun PdfLoadingStatus(isLoading: Boolean, error: String?, modifier: Modifier = Modifier) { + when { + isLoading -> CircularProgressIndicator(modifier = modifier) + error != null -> Text( + text = error, + modifier = modifier.padding(32.dp), + color = SimpleTheme.colorScheme.error, + style = SimpleTheme.typography.bodyLarge, + ) + } +} + +@Composable +internal fun PdfNavigationControls(view: PDFView?, currentPage: Int, pageCount: Int, onGoToPage: () -> Unit) { + PdfControls( + currentPage = currentPage, + pageCount = pageCount, + onGoToPage = onGoToPage, + onPreviousPage = { view?.goToPage((currentPage - 1).coerceAtLeast(0), true) }, + onNextPage = { view?.goToPage((currentPage + 1).coerceAtMost(pageCount - 1), true) }, + onZoomOut = { view?.let { it.zoomWithAnimation((it.zoom - 0.5f).coerceAtLeast(it.minZoom)) } }, + onZoomIn = { view?.let { it.zoomWithAnimation((it.zoom + 0.5f).coerceAtMost(it.maxZoom)) } }, + ) +} diff --git a/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt index d9b94b1..b709e02 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt @@ -23,10 +23,14 @@ internal fun SettingsScreen( isUseEnglishChecked: Boolean, isShowingCheckmarksOnSwitches: Boolean, rememberPdfPage: Boolean, + horizontalPdfPaging: Boolean, + showPdfPageIndicator: Boolean, showFileLocations: Boolean, onUseEnglishPress: (Boolean) -> Unit, onSetupLanguagePress: () -> Unit, onRememberPdfPageChange: (Boolean) -> Unit, + onHorizontalPdfPagingChange: (Boolean) -> Unit, + onPdfPageIndicatorChange: (Boolean) -> Unit, onShowFileLocationsChange: (Boolean) -> Unit, customizeColors: () -> Unit, goBack: () -> Unit, @@ -75,12 +79,30 @@ internal fun SettingsScreen( onChange = onShowFileLocationsChange, showCheckmark = isShowingCheckmarksOnSwitches, ) + } + + SettingsHorizontalDivider() + SettingsGroup(title = { + SettingsTitleTextComponent(text = stringResource(id = R.string.pdf_reading)) + }) { + SettingsSwitchComponent( + label = stringResource(id = R.string.pdf_horizontal_paging), + initialValue = horizontalPdfPaging, + onChange = onHorizontalPdfPagingChange, + showCheckmark = isShowingCheckmarksOnSwitches, + ) SettingsSwitchComponent( label = stringResource(id = R.string.remember_pdf_page), initialValue = rememberPdfPage, onChange = onRememberPdfPageChange, showCheckmark = isShowingCheckmarksOnSwitches, ) + SettingsSwitchComponent( + label = stringResource(id = R.string.pdf_page_indicator), + initialValue = showPdfPageIndicator, + onChange = onPdfPageIndicatorChange, + showCheckmark = isShowingCheckmarksOnSwitches, + ) } } } @@ -95,10 +117,14 @@ private fun SettingsScreenPreview() { isUseEnglishChecked = false, isShowingCheckmarksOnSwitches = false, rememberPdfPage = true, + horizontalPdfPaging = false, + showPdfPageIndicator = true, showFileLocations = false, onUseEnglishPress = {}, onSetupLanguagePress = {}, onRememberPdfPageChange = {}, + onHorizontalPdfPagingChange = {}, + onPdfPageIndicatorChange = {}, onShowFileLocationsChange = {}, customizeColors = {}, goBack = {}, diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt index e2126f6..4388947 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt @@ -15,11 +15,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.ZoomIn -import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -38,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -118,6 +113,10 @@ internal fun TextDocumentOverflowMenu( onTextZoomChange: (Float) -> Unit, onResetTextZoom: () -> Unit, onOpenWith: () -> Unit, + onSaveCopy: () -> Unit, + canSaveCopy: Boolean, + isReadOnly: Boolean, + onOpenForEditing: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -134,65 +133,60 @@ internal fun TextDocumentOverflowMenu( modifier = Modifier.widthIn(min = DocumentsMenuMinWidth), offset = DocumentsEndMenuOffset, ) { - TextDocumentMenuItem( - text = stringResource(id = R.string.zoom_in), - icon = Icons.Filled.ZoomIn, + DropdownMenuItem( + text = { + Text(stringResource(if (isReadOnly) R.string.edit_a_copy else org.fossify.commons.R.string.save_as)) + }, + enabled = canSaveCopy, + onClick = { + expanded = false + onSaveCopy() + }, + ) + if (isReadOnly && canSaveCopy) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.reopen_to_edit)) }, + onClick = { + expanded = false + onOpenForEditing() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(id = org.fossify.commons.R.string.open_with)) }, + onClick = { + expanded = false + onOpenWith() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.zoom_in)) }, enabled = textZoom < MAX_DOCUMENT_TEXT_ZOOM, onClick = { expanded = false onTextZoomChange(textZoom + DOCUMENT_TEXT_ZOOM_STEP) }, ) - TextDocumentMenuItem( - text = stringResource(id = R.string.zoom_out), - icon = Icons.Filled.ZoomOut, + DropdownMenuItem( + text = { Text(stringResource(id = R.string.zoom_out)) }, enabled = textZoom > MIN_DOCUMENT_TEXT_ZOOM, onClick = { expanded = false onTextZoomChange(textZoom - DOCUMENT_TEXT_ZOOM_STEP) }, ) - TextDocumentMenuItem( - text = stringResource(id = R.string.reset_zoom), - icon = Icons.Filled.RestartAlt, + DropdownMenuItem( + text = { Text(stringResource(id = R.string.reset_zoom)) }, enabled = textZoom != DEFAULT_DOCUMENT_TEXT_ZOOM, onClick = { expanded = false onResetTextZoom() }, ) - TextDocumentMenuItem( - text = stringResource(id = org.fossify.commons.R.string.open_with), - icon = Icons.AutoMirrored.Rounded.OpenInNew, - onClick = { - expanded = false - onOpenWith() - }, - ) } } } -@Composable -private fun TextDocumentMenuItem( - text: String, - icon: ImageVector, - enabled: Boolean = true, - onClick: () -> Unit, -) { - DropdownMenuItem( - text = { Text(text = text) }, - onClick = onClick, - enabled = enabled, - leadingIcon = { - Icon( - imageVector = icon, - contentDescription = null, - ) - }, - ) -} - @Composable internal fun Modifier.documentTextZoomGesture( textZoom: Float, diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt index e0c48f1..d4b6752 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.OutputTransformation import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.KeyboardArrowDown @@ -43,6 +42,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.onSizeChanged @@ -68,33 +69,18 @@ import kotlin.math.roundToInt @Composable internal fun TextDocumentScreen( uiState: TextDocumentUiState, + editorState: TextFieldState, onBack: () -> Unit, onTextChange: (String) -> Unit, onSave: () -> Unit, + onSaveCopy: () -> Unit, + onOpenForEditing: () -> Unit, onOpenWith: () -> Unit, onPreviewChange: (Boolean) -> Unit, textZoom: Float, onTextZoomChange: (Float) -> Unit, onResetTextZoom: () -> Unit, ) { - val editorState = rememberTextFieldState( - initialText = uiState.text, - initialSelection = TextRange.Zero, - ) - - LaunchedEffect(uiState.isLoaded, uiState.text) { - if (uiState.isLoaded && uiState.text != editorState.text.toString()) { - val currentSelection = editorState.selection - editorState.edit { - replace(0, length, uiState.text) - selection = TextRange( - start = currentSelection.start.coerceIn(0, uiState.text.length), - end = currentSelection.end.coerceIn(0, uiState.text.length), - ) - } - } - } - LaunchedEffect(editorState) { snapshotFlow { editorState.text.toString() } .drop(1) @@ -160,6 +146,8 @@ internal fun TextDocumentScreen( uiState = uiState, onBack = if (searchActive) closeSearch else onBack, onSave = onSave, + onSaveCopy = onSaveCopy, + onOpenForEditing = onOpenForEditing, onOpenWith = onOpenWith, searchActive = searchActive, searchQuery = searchQuery, @@ -186,6 +174,7 @@ internal fun TextDocumentScreen( TextDocumentContent( uiState = uiState, editorState = editorState, + onOpenForEditing = onOpenForEditing, onPreviewChange = onPreviewChange, searchMatches = searchMatches, currentSearchIndex = normalizedSearchIndex, @@ -200,6 +189,8 @@ private fun TextDocumentTopBar( uiState: TextDocumentUiState, onBack: () -> Unit, onSave: () -> Unit, + onSaveCopy: () -> Unit, + onOpenForEditing: () -> Unit, onOpenWith: () -> Unit, searchActive: Boolean, searchQuery: String, @@ -294,6 +285,10 @@ private fun TextDocumentTopBar( onTextZoomChange = onTextZoomChange, onResetTextZoom = onResetTextZoom, onOpenWith = onOpenWith, + onSaveCopy = onSaveCopy, + canSaveCopy = uiState.canSaveCopy && !uiState.isSaving, + isReadOnly = uiState.isReadOnly, + onOpenForEditing = onOpenForEditing, ) } }, @@ -308,6 +303,7 @@ private fun TextDocumentTopBar( private fun TextDocumentContent( uiState: TextDocumentUiState, editorState: TextFieldState, + onOpenForEditing: () -> Unit, onPreviewChange: (Boolean) -> Unit, searchMatches: List, currentSearchIndex: Int, @@ -327,6 +323,7 @@ private fun TextDocumentContent( else -> LoadedTextDocumentContent( uiState = uiState, editorState = editorState, + onOpenForEditing = onOpenForEditing, onPreviewChange = onPreviewChange, searchMatches = searchMatches, currentSearchIndex = currentSearchIndex, @@ -341,6 +338,7 @@ private fun TextDocumentContent( private fun ColumnScope.LoadedTextDocumentContent( uiState: TextDocumentUiState, editorState: TextFieldState, + onOpenForEditing: () -> Unit, onPreviewChange: (Boolean) -> Unit, searchMatches: List, currentSearchIndex: Int, @@ -354,15 +352,7 @@ private fun ColumnScope.LoadedTextDocumentContent( ) } - if (uiState.isReadOnly) { - StatusStrip( - text = uiState.readOnlyReason ?: stringResource(id = R.string.read_only), - isError = false, - ) - } - uiState.error?.let { - StatusStrip(text = it, isError = true) - } + TextDocumentNotices(uiState, onOpenForEditing) Box( modifier = Modifier @@ -447,41 +437,16 @@ private fun TextEditor( textZoom: Float, onTextZoomChange: (Float) -> Unit, ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(state, readOnly) { + if (!readOnly && state.text.isEmpty()) focusRequester.requestFocus() + } val scrollState = rememberScrollState() var textLayoutResult by remember { mutableStateOf(null) } var viewportHeight by remember { mutableIntStateOf(0) } var zoomGestureActive by remember { mutableStateOf(false) } val cursorMargin = with(LocalDensity.current) { CURSOR_VISIBILITY_MARGIN.toPx() } - val matchColor = SimpleTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) - val currentMatchColor = SimpleTheme.colorScheme.primaryContainer - val searchTransformation = if (shouldHighlightTextMatches(searchMatches.size)) { - remember( - searchMatches, - currentSearchIndex, - matchColor, - currentMatchColor, - ) { - OutputTransformation { - searchMatches.forEachIndexed { index, match -> - if (match.min >= 0 && match.max <= length) { - addStyle( - spanStyle = SpanStyle( - background = if (index == currentSearchIndex) { - currentMatchColor - } else { - matchColor - }, - ), - start = match.min, - end = match.max, - ) - } - } - } - } - } else { - null - } + val searchTransformation = rememberSearchHighlighting(searchMatches, currentSearchIndex) val maxScroll = scrollState.maxValue LaunchedEffect(state.selection, viewportHeight, textLayoutResult, maxScroll, cursorMargin, zoomGestureActive) { @@ -512,12 +477,13 @@ private fun TextEditor( readOnly = readOnly, modifier = Modifier .fillMaxSize() + .focusRequester(focusRequester) .documentTextZoomGesture( textZoom = textZoom, onTextZoomChange = onTextZoomChange, onZoomGestureChange = { zoomGestureActive = it }, ) - .padding(16.dp) + .padding(horizontal = 16.dp) .onSizeChanged { viewportHeight = it.height }, textStyle = SimpleTheme.typography.bodyLarge.copy( color = SimpleTheme.colorScheme.onSurface, @@ -543,5 +509,42 @@ private fun TextEditor( ) } +@Composable +private fun rememberSearchHighlighting( + searchMatches: List, + currentSearchIndex: Int, +): OutputTransformation? { + val matchColor = SimpleTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) + val currentMatchColor = SimpleTheme.colorScheme.primaryContainer + return if (shouldHighlightTextMatches(searchMatches.size)) { + remember( + searchMatches, + currentSearchIndex, + matchColor, + currentMatchColor, + ) { + OutputTransformation { + searchMatches.forEachIndexed { index, match -> + if (match.min >= 0 && match.max <= length) { + addStyle( + spanStyle = SpanStyle( + background = if (index == currentSearchIndex) { + currentMatchColor + } else { + matchColor + }, + ), + start = match.min, + end = match.max, + ) + } + } + } + } + } else { + null + } +} + private const val MARKDOWN_MODE_COUNT = 2 private val CURSOR_VISIBILITY_MARGIN = 24.dp diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt index 5a4d82f..239ffcb 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt @@ -4,19 +4,22 @@ package org.fossify.documents.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.fossify.commons.compose.theme.SimpleTheme +import org.fossify.documents.R import org.fossify.documents.viewmodels.TextDocumentUiState @Composable @@ -30,22 +33,21 @@ internal fun EditorStatusBar( contentColor = SimpleTheme.colorScheme.onSurface, tonalElevation = 2.dp, ) { - Row( + FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = uiState.text.documentStats(), + modifier = Modifier.padding(end = 12.dp), style = SimpleTheme.typography.bodyMedium, - maxLines = 1, ) Text( text = uiState.statusLabel(), style = SimpleTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, ) } } @@ -78,7 +80,11 @@ internal fun StatusDocument(text: String, isError: Boolean) { } @Composable -internal fun StatusStrip(text: String, isError: Boolean) { +internal fun StatusStrip( + text: String, + isError: Boolean, + action: (@Composable () -> Unit)? = null, +) { Surface( color = if (isError) SimpleTheme.colorScheme.errorContainer else SimpleTheme.colorScheme.secondaryContainer, contentColor = if (isError) { @@ -87,12 +93,38 @@ internal fun StatusStrip(text: String, isError: Boolean) { SimpleTheme.colorScheme.onSecondaryContainer }, ) { - Text( - text = text, + FlowRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - style = SimpleTheme.typography.bodyMedium, + .padding(start = 16.dp, end = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = text, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(end = 8.dp, top = 8.dp, bottom = 8.dp), + style = SimpleTheme.typography.bodyMedium, + ) + if (action != null) { + Box(modifier = Modifier.align(Alignment.CenterVertically)) { action() } + } + } + } +} + +@Composable +internal fun TextDocumentNotices(uiState: TextDocumentUiState, onOpenForEditing: () -> Unit) { + if (uiState.isReadOnly) { + StatusStrip( + text = uiState.readOnlyReason ?: stringResource(R.string.read_only), + isError = false, + action = if (uiState.canSaveCopy && !uiState.isSaving) { + { TextButton(onClick = onOpenForEditing) { Text(stringResource(R.string.reopen_to_edit)) } } + } else { + null + }, ) } + uiState.error?.let { StatusStrip(text = it, isError = true) } } diff --git a/app/src/main/java/org/fossify/documents/viewmodels/DocumentsViewModel.kt b/app/src/main/java/org/fossify/documents/viewmodels/DocumentsViewModel.kt index 1252584..97eec5d 100644 --- a/app/src/main/java/org/fossify/documents/viewmodels/DocumentsViewModel.kt +++ b/app/src/main/java/org/fossify/documents/viewmodels/DocumentsViewModel.kt @@ -444,12 +444,15 @@ class DocumentsViewModel( putExtra( Intent.EXTRA_MIME_TYPES, arrayOf( + "application/octet-stream", + "application/x-tex", "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "text/*", "text/markdown", "text/x-markdown", "text/csv", + "application/csv", "text/tab-separated-values", "text/html", "application/xhtml+xml", diff --git a/app/src/main/java/org/fossify/documents/viewmodels/PdfDocumentViewModel.kt b/app/src/main/java/org/fossify/documents/viewmodels/PdfDocumentViewModel.kt new file mode 100644 index 0000000..1317baf --- /dev/null +++ b/app/src/main/java/org/fossify/documents/viewmodels/PdfDocumentViewModel.kt @@ -0,0 +1,63 @@ +package org.fossify.documents.viewmodels + +import android.app.Application +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.fossify.documents.R +import org.fossify.documents.data.DocumentsRepository +import java.io.File +import java.io.IOException + +class PdfDocumentViewModel(application: Application) : AndroidViewModel(application) { + private val resolver = application.contentResolver + private val repository = DocumentsRepository(application) + private val messages = Channel(Channel.BUFFERED) + val copyMessages = messages.receiveAsFlow() + var isCopying by mutableStateOf(false) + private set + + fun saveCopy(source: Uri, destination: Uri, grantFlags: Int) { + if (isCopying) return + isCopying = true + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + copyDocument(source, destination) + repository.rememberDocument(destination, grantFlags) + } + messages.send(org.fossify.commons.R.string.file_saved) + } catch (_: IOException) { + messages.send(R.string.could_not_save_document) + } catch (_: SecurityException) { + messages.send(R.string.could_not_save_document) + } finally { + isCopying = false + } + } + } + + private fun copyDocument(source: Uri, destination: Uri) { + val temporary = File.createTempFile("pdf-copy-", ".pdf", getApplication().cacheDir) + try { + val input = resolver.openInputStream(source) ?: throw IOException() + input.use { sourceStream -> + temporary.outputStream().use { sourceStream.copyTo(it) } + } + temporary.inputStream().use { sourceStream -> + val output = resolver.openOutputStream(destination, "wt") ?: throw IOException() + output.use { sourceStream.copyTo(it) } + } + } finally { + temporary.delete() + } + } +} diff --git a/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt b/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt index bfb99d4..1640a90 100644 --- a/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt +++ b/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.launch import org.fossify.commons.extensions.getFilenameFromUri import org.fossify.documents.R import org.fossify.documents.data.DocumentTooLargeException -import org.fossify.documents.data.DocumentsRepository import org.fossify.documents.data.StructuredDocumentContent import org.fossify.documents.data.StructuredDocumentLoader import org.fossify.documents.models.DocumentKind @@ -22,7 +21,6 @@ internal class StructuredDocumentViewModel( application: Application, ) : AndroidViewModel(application) { private val loader = StructuredDocumentLoader(application.contentResolver) - private val repository = DocumentsRepository(application) private val _uiState = MutableStateFlow(StructuredDocumentUiState()) val uiState: StateFlow = _uiState @@ -48,9 +46,7 @@ internal class StructuredDocumentViewModel( _uiState.update { it.copy( content = result.content, - canEdit = kind == DocumentKind.CSV && - result.canEditText && - repository.isDocumentWritable(uri), + canEdit = kind == DocumentKind.CSV && result.canEditText, isLoading = false, error = null, ) diff --git a/app/src/main/java/org/fossify/documents/viewmodels/TextDocumentViewModel.kt b/app/src/main/java/org/fossify/documents/viewmodels/TextDocumentViewModel.kt index 5b3441e..3aa396d 100644 --- a/app/src/main/java/org/fossify/documents/viewmodels/TextDocumentViewModel.kt +++ b/app/src/main/java/org/fossify/documents/viewmodels/TextDocumentViewModel.kt @@ -2,9 +2,15 @@ package org.fossify.documents.viewmodels import android.app.Application import android.net.Uri +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -12,12 +18,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fossify.commons.extensions.getFilenameFromUri import org.fossify.documents.R +import org.fossify.documents.data.DecodedTextDocument import org.fossify.documents.data.DocumentsRepository import org.fossify.documents.data.TextDocumentCodec import org.fossify.documents.data.TextDocumentEncoding import org.fossify.documents.models.DocumentKind import java.io.IOException +@Suppress("TooManyFunctions") class TextDocumentViewModel( application: Application, ) : AndroidViewModel(application) { @@ -26,19 +34,30 @@ class TextDocumentViewModel( private val _uiState = MutableStateFlow(TextDocumentUiState()) val uiState: StateFlow = _uiState + var editorState by mutableStateOf(TextFieldState()) + private set private var loadedUri: Uri? = null + val documentUri: Uri? get() = loadedUri private var documentEncoding: TextDocumentEncoding? = null + private var loadJob: Job? = null + private var saveCopyPending = false - @Volatile private var originalText: String = "" private val app: Application get() = getApplication() - fun load(uri: Uri, kind: DocumentKind) { - if (loadedUri == uri) { + fun load( + uri: Uri, + kind: DocumentKind, + force: Boolean = false, + ) { + if (!force && loadedUri != null) { return } + loadJob?.cancel() + saveCopyPending = false loadedUri = uri + documentEncoding = null _uiState.value = TextDocumentUiState( title = app.getFilenameFromUri(uri), kind = kind, @@ -46,27 +65,15 @@ class TextDocumentViewModel( previewEnabled = kind == DocumentKind.MARKDOWN, ) - viewModelScope.launch(Dispatchers.IO) { + loadJob = viewModelScope.launch { try { - val decoded = resolver.openInputStream(uri)?.use { input -> - TextDocumentCodec.decode(input.readBytes()) - } ?: throw IOException() - val unsupportedEncoding = decoded.encoding == null - val isReadOnly = unsupportedEncoding || !repository.isDocumentWritable(uri) - documentEncoding = decoded.encoding - originalText = decoded.text - _uiState.update { - it.copy( - text = decoded.text, - isLoading = false, - isLoaded = true, - isReadOnly = isReadOnly, - readOnlyReason = app.getString(R.string.unsupported_text_encoding) - .takeIf { unsupportedEncoding }, - isDirty = false, - error = null, - ) + val (decoded, writable) = withContext(Dispatchers.IO) { + val document = resolver.openInputStream(uri)?.use { input -> + TextDocumentCodec.decode(input.readBytes()) + } ?: throw IOException() + document to repository.isDocumentWritable(uri) } + showDocument(decoded, writable) } catch (error: OutOfMemoryError) { _uiState.update { it.copy( @@ -82,6 +89,24 @@ class TextDocumentViewModel( } } + private fun showDocument(decoded: DecodedTextDocument, writable: Boolean) { + documentEncoding = decoded.encoding + originalText = decoded.text + editorState = TextFieldState(initialText = decoded.text, initialSelection = TextRange.Zero) + _uiState.update { + it.copy( + text = decoded.text, + isLoading = false, + isLoaded = true, + isReadOnly = decoded.encoding == null || !writable, + canSaveCopy = decoded.encoding != null, + readOnlyReason = app.getString(R.string.unsupported_text_encoding) + .takeIf { decoded.encoding == null }, + previewEnabled = it.previewEnabled && decoded.text.isNotEmpty(), + ) + } + } + fun onTextChange(value: String) { _uiState.update { it.copy( @@ -98,44 +123,89 @@ class TextDocumentViewModel( } fun save(onSaved: (() -> Unit)? = null) { - val uri = loadedUri ?: return + if (!_uiState.value.isReadOnly) { + val uri = loadedUri ?: return + viewModelScope.launch { + saveTo(uri, onSaved = onSaved) + } + } + } + + fun prepareSaveCopy(): Boolean { + val state = _uiState.value + if (!state.isLoaded || !state.canSaveCopy || state.isSaving) return false + saveCopyPending = true + return true + } + + fun cancelSaveCopy() { + saveCopyPending = false + } + + fun saveCopy(uri: Uri, grantFlags: Int, onSaved: (() -> Unit)? = null) { + if (!saveCopyPending) { + _uiState.update { it.copy(error = app.getString(R.string.could_not_save_document)) } + return + } + saveCopyPending = false + viewModelScope.launch { + saveTo(uri, grantFlags, onSaved) + } + } + + private suspend fun saveTo(uri: Uri, copyGrantFlags: Int? = null, onSaved: (() -> Unit)? = null) { val encoding = documentEncoding ?: return val currentState = _uiState.value - if (currentState.isReadOnly || currentState.isSaving || !currentState.isLoaded) { + if (currentState.isSaving || !currentState.isLoaded) { return } val text = currentState.text _uiState.update { it.copy(isSaving = true, error = null) } - viewModelScope.launch(Dispatchers.IO) { - try { - resolver.openOutputStream(uri, "wt").use { output -> - output?.write(TextDocumentCodec.encode(text, encoding)) - ?: throw IOException() - } - repository.refreshDocumentMetadata(uri) - originalText = text - _uiState.update { - val hasNewerChanges = it.text != text - it.copy( - isSaving = false, - isDirty = hasNewerChanges, - ) - } - withContext(Dispatchers.Main) { - if (_uiState.value.text == text) { - onSaved?.invoke() - } - } - } catch (error: IOException) { - showSaveError(error) - } catch (error: SecurityException) { - showSaveError(error) - } catch (error: IllegalStateException) { - showSaveError(error) + try { + val (title, writable) = writeDocument(uri, text, encoding, copyGrantFlags) + loadedUri = uri + originalText = text + _uiState.update { + val hasNewerChanges = it.text != text + it.copy( + title = title, + isSaving = false, + isDirty = hasNewerChanges, + isReadOnly = !writable, + readOnlyReason = null, + previewEnabled = !(copyGrantFlags != null && currentState.isReadOnly) && it.previewEnabled, + ) + } + if (_uiState.value.text == text) { + onSaved?.invoke() } + } catch (error: IOException) { + showSaveError(error) + } catch (error: SecurityException) { + showSaveError(error) + } catch (error: IllegalStateException) { + showSaveError(error) + } + } + + private suspend fun writeDocument( + uri: Uri, + text: String, + encoding: TextDocumentEncoding, + copyGrantFlags: Int?, + ): Pair = withContext(Dispatchers.IO) { + resolver.openOutputStream(uri, "wt").use { output -> + output?.write(TextDocumentCodec.encode(text, encoding)) + ?: throw IOException() + } + if (copyGrantFlags != null) { + repository.rememberDocument(uri, copyGrantFlags) + } else { + repository.refreshDocumentMetadata(uri) } + app.getFilenameFromUri(uri) to repository.isDocumentWritable(uri) } private fun showOpenError(error: Throwable) { @@ -165,6 +235,7 @@ data class TextDocumentUiState( val isLoaded: Boolean = false, val isSaving: Boolean = false, val isReadOnly: Boolean = false, + val canSaveCopy: Boolean = false, val readOnlyReason: String? = null, val isDirty: Boolean = false, val previewEnabled: Boolean = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8421a9..d1230ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,4 +98,19 @@ Reset zoom Zoom in Zoom out + Save a copy + Edit a copy + Reopen to edit + Select this file again to request permission to save changes to it. + Copy saved and opened + Page %1$d of %2$d. Go to page. + Go to page + Page (1–%1$d) + Dark pages + Floating page indicator + Contents + This PDF has no searchable text. + Could not complete the search. + PDF reading + Scroll PDFs horizontally diff --git a/app/src/test/java/org/fossify/documents/models/DocumentKindTest.kt b/app/src/test/java/org/fossify/documents/models/DocumentKindTest.kt index 59ed29d..e99842a 100644 --- a/app/src/test/java/org/fossify/documents/models/DocumentKindTest.kt +++ b/app/src/test/java/org/fossify/documents/models/DocumentKindTest.kt @@ -32,13 +32,6 @@ class DocumentKindTest { assertEquals(DocumentKind.HTML, DocumentKind.fromName("page", "application/xhtml+xml")) } - @Test - fun `specific text formats win over generic text mime type`() { - assertEquals(DocumentKind.MARKDOWN, DocumentKind.fromName("README.md", "text/plain")) - assertEquals(DocumentKind.CSV, DocumentKind.fromName("budget.csv", "text/plain")) - assertEquals(DocumentKind.HTML, DocumentKind.fromName("article.htm", "text/plain")) - } - @Test fun `does not treat legacy word or excel files as supported`() { assertEquals(DocumentKind.OTHER, DocumentKind.fromName("letter.doc", "application/msword")) diff --git a/app/src/test/java/org/fossify/documents/ui/screens/TextDocumentDisplayTest.kt b/app/src/test/java/org/fossify/documents/ui/screens/TextDocumentDisplayTest.kt index 046c766..d472fdc 100644 --- a/app/src/test/java/org/fossify/documents/ui/screens/TextDocumentDisplayTest.kt +++ b/app/src/test/java/org/fossify/documents/ui/screens/TextDocumentDisplayTest.kt @@ -35,10 +35,9 @@ class TextDocumentDisplayTest { } @Test - fun `coerces invalid and out of range zoom`() { + fun `clamps zoom to the supported range`() { assertEquals(MIN_DOCUMENT_TEXT_ZOOM, 0.1f.coerceDocumentTextZoom()) assertEquals(MAX_DOCUMENT_TEXT_ZOOM, 10f.coerceDocumentTextZoom()) - assertEquals(DEFAULT_DOCUMENT_TEXT_ZOOM, Float.NaN.coerceDocumentTextZoom()) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4168a0..f9ac5ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ detekt = "1.23.8" detektCompose = "0.4.28" #Fossify commons = "6.2.0" -androidPdfViewer = "3.2.16" +androidPdfViewer = "a41e24cc90" javaMammoth = "1.12.1" commonmark = "0.30.0" jsoup = "1.23.2" @@ -20,7 +20,7 @@ androidx-lifecycle = "2.10.0" junit = "4.13.2" #Gradle gradlePlugins-agp = "9.4.1" -app-build-compileSDKVersion = "36" +app-build-compileSDKVersion = "37" app-build-targetSDK = "36" app-build-minimumSDK = "26" app-build-javaVersion = "VERSION_17" @@ -30,7 +30,7 @@ app-build-kotlinJVMTarget = "17" compose-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } #Fossify fossify-commons = { module = "org.fossify:commons", version.ref = "commons" } -android-pdf-viewer = { module = "com.github.Infomaniak:android-pdfview", version.ref = "androidPdfViewer" } +android-pdf-viewer = { module = "com.github.naveensingh:android-pdfview", version.ref = "androidPdfViewer" } java-mammoth = { module = "org.fossify:java-mammoth", version.ref = "javaMammoth" } commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } From 38daa32f5af974cc6e85fac391b1103e3fb64dbf Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 09:00:12 +0530 Subject: [PATCH 2/6] fix: address detekt issues --- .../data/DocumentLocationResolver.kt | 26 +++++++++++++------ .../documents/ui/screens/PdfViewerChrome.kt | 1 - 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt b/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt index a0da9c0..6c1c14f 100644 --- a/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt +++ b/app/src/main/java/org/fossify/documents/data/DocumentLocationResolver.kt @@ -31,10 +31,17 @@ internal class DocumentLocationResolver( if (DocumentsContract.isDocumentUri(appContext, uri) || DocumentsContract.isTreeUri(uri)) { return uri } - if (uri.authority == MediaStore.AUTHORITY) { - runCatching { MediaStore.getDocumentUri(appContext, uri) }.getOrNull()?.let { return it } + val documentUri = if (uri.authority == MediaStore.AUTHORITY) { + runCatching { MediaStore.getDocumentUri(appContext, uri) }.getOrNull() + } else { + null } - val realPath = appContext.getRealPathFromURI(uri)?.takeIf { it.isNotBlank() } ?: return null + return documentUri ?: appContext.getRealPathFromURI(uri) + ?.takeIf { it.isNotBlank() } + ?.let(::resolveFilePickerLocation) + } + + private fun resolveFilePickerLocation(realPath: String): Uri? { return runCatching { val folder = File(realPath).canonicalFile.parentFile ?: return null val volume = appContext.getSystemService(StorageManager::class.java) @@ -45,11 +52,14 @@ internal class DocumentLocationResolver( } else { File("/storage", volumeId).canonicalFile } - if (!folder.startsWith(root)) return null - DocumentsContract.buildDocumentUri( - EXTERNAL_STORAGE_AUTHORITY, - "$volumeId:${folder.relativeTo(root).invariantSeparatorsPath}", - ) + if (folder.startsWith(root)) { + DocumentsContract.buildDocumentUri( + EXTERNAL_STORAGE_AUTHORITY, + "$volumeId:${folder.relativeTo(root).invariantSeparatorsPath}", + ) + } else { + null + } }.getOrNull() } diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt index 3e0fa9c..8af8c34 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt @@ -1,5 +1,4 @@ @file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber") package org.fossify.documents.ui.screens From bbb4ef870a0eb8eda72fc60fb2685b4a481b778e Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 15:15:33 +0530 Subject: [PATCH 3/6] fix: remove unnecessary detekt suppressions --- .../org/fossify/documents/activities/TextDocumentActivity.kt | 2 -- .../main/java/org/fossify/documents/extensions/Activity.kt | 2 -- .../org/fossify/documents/ui/screens/DocumentGridCards.kt | 4 ---- .../java/org/fossify/documents/ui/screens/DocumentIcons.kt | 2 -- .../java/org/fossify/documents/ui/screens/DocumentRows.kt | 4 ---- .../fossify/documents/ui/screens/DocumentsFocusedChrome.kt | 4 +--- .../documents/ui/screens/DocumentsFocusedEmptyStates.kt | 2 -- .../org/fossify/documents/ui/screens/DocumentsHomeActions.kt | 4 ---- .../org/fossify/documents/ui/screens/DocumentsHomeChrome.kt | 1 - .../fossify/documents/ui/screens/DocumentsSelectionTopBar.kt | 1 - .../org/fossify/documents/ui/screens/DocumentsSortViewMenu.kt | 4 ---- .../org/fossify/documents/ui/screens/FolderCollectionItems.kt | 4 ---- .../main/java/org/fossify/documents/ui/screens/MainScreen.kt | 2 -- .../org/fossify/documents/ui/screens/MarkdownEditAction.kt | 2 -- .../fossify/documents/ui/screens/MarkdownFormattingToolbar.kt | 2 -- .../java/org/fossify/documents/ui/screens/MarkdownPreview.kt | 2 -- .../org/fossify/documents/ui/screens/PdfDocumentScreen.kt | 2 +- .../java/org/fossify/documents/ui/screens/SettingsScreen.kt | 2 +- .../fossify/documents/ui/screens/StructuredDocumentScreen.kt | 2 +- .../org/fossify/documents/ui/screens/TextDocumentControls.kt | 4 ---- .../org/fossify/documents/ui/screens/TextDocumentScreen.kt | 1 - .../documents/ui/screens/TextDocumentStatusComponents.kt | 2 -- .../fossify/documents/ui/theme/DocumentsAppThemeSurface.kt | 2 -- .../documents/viewmodels/StructuredDocumentViewModel.kt | 1 - 24 files changed, 4 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt b/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt index ab9f3eb..2f581d6 100644 --- a/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt +++ b/app/src/main/java/org/fossify/documents/activities/TextDocumentActivity.kt @@ -1,5 +1,3 @@ -@file:Suppress("LongMethod") - package org.fossify.documents.activities import android.content.ActivityNotFoundException diff --git a/app/src/main/java/org/fossify/documents/extensions/Activity.kt b/app/src/main/java/org/fossify/documents/extensions/Activity.kt index 5e979a6..680bf8c 100644 --- a/app/src/main/java/org/fossify/documents/extensions/Activity.kt +++ b/app/src/main/java/org/fossify/documents/extensions/Activity.kt @@ -1,5 +1,3 @@ -@file:Suppress("LongParameterList") - package org.fossify.documents.extensions import android.app.Activity diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentGridCards.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentGridCards.kt index c69efd6..ed09d28 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentGridCards.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentGridCards.kt @@ -1,12 +1,8 @@ -@file:OptIn(ExperimentalFoundationApi::class) -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.screens import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentIcons.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentIcons.kt index 7f0fce9..0cb2d4e 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentIcons.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentIcons.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import android.text.format.DateUtils diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentRows.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentRows.kt index f45868d..960ea1b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentRows.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentRows.kt @@ -1,11 +1,7 @@ -@file:OptIn(ExperimentalFoundationApi::class) -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.screens import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedChrome.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedChrome.kt index fc27589..6fd831b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedChrome.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedChrome.kt @@ -1,5 +1,4 @@ -@file:OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "LongParameterList", "MagicNumber") +@file:OptIn(ExperimentalMaterial3Api::class) package org.fossify.documents.ui.screens @@ -30,7 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedEmptyStates.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedEmptyStates.kt index c7da778..5caae6b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedEmptyStates.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsFocusedEmptyStates.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.screens import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt index 61b04dd..cd2f269 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeActions.kt @@ -1,6 +1,3 @@ -@file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.layout.Arrangement @@ -21,7 +18,6 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt index 56c19cf..045370d 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsHomeChrome.kt @@ -1,5 +1,4 @@ @file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "MagicNumber") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt index a0625f3..f5372bd 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSelectionTopBar.kt @@ -1,5 +1,4 @@ @file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSortViewMenu.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSortViewMenu.kt index d49aa61..f5c1ee6 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSortViewMenu.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentsSortViewMenu.kt @@ -1,6 +1,3 @@ -@file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.layout.Arrangement @@ -19,7 +16,6 @@ import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text diff --git a/app/src/main/java/org/fossify/documents/ui/screens/FolderCollectionItems.kt b/app/src/main/java/org/fossify/documents/ui/screens/FolderCollectionItems.kt index 67694f1..22aa83b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/FolderCollectionItems.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/FolderCollectionItems.kt @@ -1,9 +1,5 @@ -@file:OptIn(ExperimentalFoundationApi::class) -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.screens -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/java/org/fossify/documents/ui/screens/MainScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/MainScreen.kt index 3c87aaf..43c9939 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/MainScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/MainScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.screens import androidx.activity.compose.BackHandler diff --git a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownEditAction.kt b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownEditAction.kt index 39719d7..81cdfb2 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownEditAction.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownEditAction.kt @@ -1,5 +1,3 @@ -@file:Suppress("MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.text.input.TextFieldState diff --git a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownFormattingToolbar.kt b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownFormattingToolbar.kt index ba18701..3cd422d 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownFormattingToolbar.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownFormattingToolbar.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.horizontalScroll diff --git a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownPreview.kt b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownPreview.kt index 45be2d3..6e9ca76 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/MarkdownPreview.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/MarkdownPreview.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.BorderStroke diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt index 3037b98..296748b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfDocumentScreen.kt @@ -1,4 +1,4 @@ -@file:Suppress("FunctionNaming", "CyclomaticComplexMethod", "LongMethod", "LongParameterList", "MagicNumber") +@file:Suppress("CyclomaticComplexMethod") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt index b709e02..ca135cc 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/SettingsScreen.kt @@ -1,4 +1,4 @@ -@file:Suppress("FunctionNaming", "LongParameterList", "UnusedPrivateMember") +@file:Suppress("UnusedPrivateMember") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt index f7a2a3b..f0cd045 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt @@ -1,5 +1,5 @@ @file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "LongMethod", "MagicNumber") +@file:Suppress("MagicNumber") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt index 4388947..4ead920 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt @@ -1,6 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) -@file:Suppress("FunctionNaming", "LongParameterList", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.gestures.awaitEachGesture @@ -29,7 +26,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt index d4b6752..2577746 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt @@ -1,5 +1,4 @@ @file:OptIn(ExperimentalMaterial3Api::class) -@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber") package org.fossify.documents.ui.screens diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt index 239ffcb..1f97490 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentStatusComponents.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming", "MagicNumber") - package org.fossify.documents.ui.screens import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/org/fossify/documents/ui/theme/DocumentsAppThemeSurface.kt b/app/src/main/java/org/fossify/documents/ui/theme/DocumentsAppThemeSurface.kt index 2bd266b..52564a2 100644 --- a/app/src/main/java/org/fossify/documents/ui/theme/DocumentsAppThemeSurface.kt +++ b/app/src/main/java/org/fossify/documents/ui/theme/DocumentsAppThemeSurface.kt @@ -1,5 +1,3 @@ -@file:Suppress("FunctionNaming") - package org.fossify.documents.ui.theme import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt b/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt index 1640a90..f82d6c0 100644 --- a/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt +++ b/app/src/main/java/org/fossify/documents/viewmodels/StructuredDocumentViewModel.kt @@ -27,7 +27,6 @@ internal class StructuredDocumentViewModel( private var loadedUri: Uri? = null private val app: Application get() = getApplication() - @Suppress("TooGenericExceptionCaught") fun load(uri: Uri, kind: DocumentKind, force: Boolean = false) { if (!force && uri == loadedUri) { return From 3a36e741b34536ae1b889435e76a5349c4c54f03 Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 15:20:19 +0530 Subject: [PATCH 4/6] feat: support text search in DOCX, and HTML files --- CHANGELOG.md | 2 +- .../ui/screens/StructuredDocumentScreen.kt | 110 +++++++++++---- .../ui/screens/WebDocumentSearchState.kt | 125 ++++++++++++++++++ 3 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/WebDocumentSearchState.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a571c17..b81b323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PDF text selection and copying - Option for dark PDF pages - Option for horizontal PDF scrolling -- PDF text search ([#87]) +- PDF, DOCX, and HTML text search ([#87]) - PDF table of contents ([#77]) - Option to hide floating PDF page numbers ([#79]) - Reopen to edit button for read-only files ([#71]) diff --git a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt index f0cd045..051f02b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt @@ -11,6 +11,7 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -32,6 +34,9 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -44,8 +49,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -80,6 +88,14 @@ internal fun StructuredDocumentScreen( onOpenWith: () -> Unit, onOpenLink: (Uri) -> Unit, ) { + var searchActive by rememberSaveable { mutableStateOf(false) } + val search = rememberSaveable(saver = WebDocumentSearchState.saver) { WebDocumentSearchState() } + val closeSearch = { + searchActive = false + search.updateQuery("") + } + BackHandler(enabled = searchActive, onBack = closeSearch) + SimpleScaffold( customTopBar = { scrolledColor: Color, _, @@ -89,7 +105,14 @@ internal fun StructuredDocumentScreen( contrastColor: Color -> StructuredDocumentTopBar( title = uiState.title, - onBack = onBack, + onBack = if (searchActive) closeSearch else onBack, + searchActive = searchActive, + search = search, + onSearch = if (uiState.content is StructuredDocumentContent.Web && !uiState.isLoading) { + { searchActive = true } + } else { + null + }, onEdit = onEdit, onOpenWith = onOpenWith, scrolledColor = scrolledColor, @@ -103,13 +126,15 @@ internal fun StructuredDocumentScreen( Box( modifier = Modifier .fillMaxSize() - .background(SimpleTheme.colorScheme.surface), + .background(SimpleTheme.colorScheme.surface) + .imePadding(), ) { when { uiState.isLoading -> LoadingDocument() uiState.error != null -> StatusDocument(uiState.error, isError = true) uiState.content is StructuredDocumentContent.Web -> WebDocument( content = uiState.content, + search = search, onOpenLink = onOpenLink, ) @@ -127,6 +152,9 @@ internal fun StructuredDocumentScreen( private fun StructuredDocumentTopBar( title: String, onBack: () -> Unit, + searchActive: Boolean, + search: WebDocumentSearchState, + onSearch: (() -> Unit)?, onEdit: (() -> Unit)?, onOpenWith: () -> Unit, scrolledColor: Color, @@ -137,13 +165,23 @@ private fun StructuredDocumentTopBar( ) { TopAppBar( title = { - Text( - text = title.ifBlank { stringResource(id = R.string.document) }, - color = scrolledColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = SimpleTheme.typography.titleLarge, - ) + if (searchActive) { + TextDocumentSearchField( + query = search.query, + currentMatchNumber = search.currentMatchNumber, + matchCount = search.matchCount, + onQueryChange = search::updateQuery, + onNextMatch = { search.moveMatch(forward = true) }, + ) + } else { + Text( + text = title.ifBlank { stringResource(id = R.string.document) }, + color = scrolledColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = SimpleTheme.typography.titleLarge, + ) + } }, navigationIcon = { IconButton(onClick = onBack) { @@ -155,22 +193,40 @@ private fun StructuredDocumentTopBar( } }, actions = { - if (onEdit != null) { - IconButton(onClick = onEdit) { + if (searchActive) { + IconButton(onClick = { search.moveMatch(forward = false) }, enabled = search.matchCount > 0) { + Icon(Icons.Filled.KeyboardArrowUp, stringResource(R.string.previous_match)) + } + IconButton(onClick = { search.moveMatch(forward = true) }, enabled = search.matchCount > 0) { + Icon(Icons.Filled.KeyboardArrowDown, stringResource(R.string.next_match)) + } + } else { + if (onSearch != null) { + IconButton(onClick = onSearch) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.search_text_in_document), + tint = scrolledColor, + ) + } + } + if (onEdit != null) { + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(id = R.string.edit_as_text), + tint = scrolledColor, + ) + } + } + IconButton(onClick = onOpenWith) { Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(id = R.string.edit_as_text), + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(id = org.fossify.commons.R.string.open_with), tint = scrolledColor, ) } } - IconButton(onClick = onOpenWith) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = stringResource(id = org.fossify.commons.R.string.open_with), - tint = scrolledColor, - ) - } }, scrollBehavior = scrollBehavior, colors = simpleTopAppBarColors(statusBarColor, colorTransitionFraction, contrastColor), @@ -183,6 +239,7 @@ private fun StructuredDocumentTopBar( @Composable private fun WebDocument( content: StructuredDocumentContent.Web, + search: WebDocumentSearchState, onOpenLink: (Uri) -> Unit, ) { val context = LocalContext.current @@ -232,6 +289,10 @@ private fun WebDocument( settings.builtInZoomControls = true settings.displayZoomControls = false webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + search.pageFinished() + } + override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest, @@ -261,15 +322,18 @@ private fun WebDocument( } } - LaunchedEffect(webView, page) { - webView.loadDataWithBaseURL(WEB_BASE_URL, page, "text/html", "UTF-8", null) - } DisposableEffect(webView) { + search.attach(webView) onDispose { + search.detach() webView.stopLoading() webView.destroy() } } + LaunchedEffect(webView, page) { + search.pageStarted() + webView.loadDataWithBaseURL(WEB_BASE_URL, page, "text/html", "UTF-8", null) + } AndroidView( factory = { webView }, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/org/fossify/documents/ui/screens/WebDocumentSearchState.kt b/app/src/main/java/org/fossify/documents/ui/screens/WebDocumentSearchState.kt new file mode 100644 index 0000000..d336c8f --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/WebDocumentSearchState.kt @@ -0,0 +1,125 @@ +package org.fossify.documents.ui.screens + +import android.view.View +import android.webkit.WebView +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue + +internal class WebDocumentSearchState( + initialQuery: String = "", + initialMatchIndex: Int = 0, +) { + var query by mutableStateOf(initialQuery) + private set + var activeMatchIndex by mutableIntStateOf(initialMatchIndex) + private set + var matchCount by mutableIntStateOf(0) + private set + + val currentMatchNumber: Int + get() = if (matchCount > 0) activeMatchIndex + 1 else 0 + + private var webView: WebView? = null + private var pageReady = false + private var restoredMatchIndex: Int? = initialMatchIndex + private var needsMatchReveal = false + private val layoutListener = + View.OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) { + val view = webView ?: return@OnLayoutChangeListener + view.postVisualStateCallback(0, object : WebView.VisualStateCallback() { + override fun onComplete(requestId: Long) { + if (webView === view) { + needsMatchReveal = query.isNotEmpty() + revealCurrentMatch() + } + } + }) + } + } + + fun attach(view: WebView) { + webView = view + view.setFindListener(::onFindResult) + view.addOnLayoutChangeListener(layoutListener) + } + + fun detach() { + webView?.setFindListener(null) + webView?.removeOnLayoutChangeListener(layoutListener) + webView = null + pageReady = false + needsMatchReveal = false + } + + fun pageStarted() { + pageReady = false + matchCount = 0 + restoredMatchIndex = activeMatchIndex + } + + fun pageFinished() { + if (!pageReady) { + pageReady = true + findMatches() + } + } + + fun updateQuery(value: String) { + if (query == value) return + query = value + activeMatchIndex = 0 + matchCount = 0 + restoredMatchIndex = 0 + needsMatchReveal = false + findMatches() + } + + fun moveMatch(forward: Boolean) { + if (matchCount > 0) { + webView?.findNext(forward) + } + } + + private fun findMatches() { + val view = webView ?: return + if (!pageReady) return + view.findAllAsync(query) + if (query.isEmpty()) { + view.clearMatches() + } + } + + private fun onFindResult(ordinal: Int, count: Int, doneCounting: Boolean) { + if (!pageReady || query.isEmpty() || !doneCounting) return + val target = restoredMatchIndex?.takeIf { count > 0 }?.coerceIn(0, count - 1) + if (target != null && target != ordinal) { + val forwardDistance = (target - ordinal).mod(count) + val backwardDistance = (ordinal - target).mod(count) + webView?.findNext(forwardDistance <= backwardDistance) + return + } + restoredMatchIndex = null + activeMatchIndex = if (count > 0) ordinal else 0 + matchCount = count + revealCurrentMatch() + } + + private fun revealCurrentMatch() { + val view = webView ?: return + if (!needsMatchReveal || matchCount == 0 || restoredMatchIndex != null) return + needsMatchReveal = false + restoredMatchIndex = activeMatchIndex + view.findNext(false) + } + + companion object { + val saver = listSaver( + save = { listOf(it.query, it.activeMatchIndex) }, + restore = { WebDocumentSearchState(it[0] as String, it[1] as Int) }, + ) + } +} From bc6682b13fa41e6de87a0ce6f5e3ccddaa2d661f Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 21:09:40 +0530 Subject: [PATCH 5/6] fix: harmonize search highlight colors --- .../ui/screens/DocumentSearchColors.kt | 26 +++++++++ .../documents/ui/screens/PdfPageView.kt | 53 +++++++++++-------- .../ui/screens/StructuredDocumentScreen.kt | 18 ++++++- .../ui/screens/TextDocumentScreen.kt | 5 +- 4 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/fossify/documents/ui/screens/DocumentSearchColors.kt diff --git a/app/src/main/java/org/fossify/documents/ui/screens/DocumentSearchColors.kt b/app/src/main/java/org/fossify/documents/ui/screens/DocumentSearchColors.kt new file mode 100644 index 0000000..882dd70 --- /dev/null +++ b/app/src/main/java/org/fossify/documents/ui/screens/DocumentSearchColors.kt @@ -0,0 +1,26 @@ +package org.fossify.documents.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.fossify.commons.compose.theme.SimpleTheme +import org.fossify.commons.compose.theme.isSurfaceNotLitWell + +internal data class DocumentSearchColors( + val accent: Color, + val match: Color, + val currentMatch: Color, +) + +@Composable +internal fun documentSearchColors(darkBackground: Boolean = isSurfaceNotLitWell()): DocumentSearchColors { + val accent = if (darkBackground == isSurfaceNotLitWell()) { + SimpleTheme.colorScheme.primary + } else { + SimpleTheme.colorScheme.inversePrimary + } + return DocumentSearchColors( + accent = accent, + match = accent.copy(alpha = 0.18f), + currentMatch = accent.copy(alpha = 0.5f), + ) +} diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt index 336e117..c39da5b 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt @@ -26,7 +26,6 @@ import com.infomaniak.lib.pdfview.model.LinkTapEvent import com.infomaniak.lib.pdfview.scroll.DefaultScrollHandle import com.infomaniak.lib.pdfview.util.FitPolicy import org.fossify.commons.compose.theme.SimpleTheme -import org.fossify.commons.compose.theme.isSurfaceNotLitWell import org.fossify.documents.data.PdfTextMatch @Composable @@ -49,20 +48,25 @@ internal fun PdfPageView( ) { var view by remember { mutableStateOf(null) } var viewSize by remember { mutableStateOf(IntSize.Zero) } - var pageIndicator by remember { mutableStateOf(null) } + var pageIndicator by remember { mutableStateOf(null) } val currentShowPageIndicator by rememberUpdatedState(showPageIndicator) val currentSelectedMatch by rememberUpdatedState(search.selectedMatch) val matchesByPage by rememberUpdatedState(remember(search.result) { search.result.matches.groupBy { it.page } }) val backgroundColor = SimpleTheme.colorScheme.surfaceVariant.toArgb() - val scrollHandleTextColor = SimpleTheme.colorScheme.onSurfaceVariant.toArgb() val colors = SimpleTheme.colorScheme - val highlightColor = pdfHighlightColor(nightMode) + val searchColors = documentSearchColors(darkBackground = nightMode) + val highlightColor = searchColors.accent val selectionColor = highlightColor.toArgb() val selectionHighlight = highlightColor.copy(alpha = 0.3f).toArgb() - val matchColor by rememberUpdatedState(highlightColor.copy(alpha = MATCH_HIGHLIGHT_ALPHA).toArgb()) - val currentMatchColor by rememberUpdatedState(highlightColor.copy(alpha = CURRENT_MATCH_HIGHLIGHT_ALPHA).toArgb()) + val matchColor by rememberUpdatedState(searchColors.match.toArgb()) + val currentMatchColor by rememberUpdatedState(searchColors.currentMatch.toArgb()) LaunchedEffect(nightMode, colors, view) { view?.apply { + setBackgroundColor(backgroundColor) + pageIndicator?.updateColors( + context.scrollHandleBackground(horizontalPaging, colors.primary.toArgb()), + colors.onPrimary.toArgb(), + ) setNightMode(nightMode) setSelectionHandleColor(selectionColor) setSelectionHighlightColor(selectionHighlight) @@ -108,7 +112,7 @@ internal fun PdfPageView( .onSelectionChange(onSelectionChange) .onSelectionAction { text -> val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(title, text)) + clipboard.primaryClip = ClipData.newPlainText(title, text) clearTextSelection() } .onDrawAll { canvas, width, height, page -> @@ -129,13 +133,11 @@ internal fun PdfPageView( .enableAntialiasing(true) .readingMode(horizontalPaging) .scrollHandle( - object : DefaultScrollHandle(context) { - override fun show() { - if (currentShowPageIndicator) super.show() - } - }.apply { - setPageHandleBackground(context.scrollHandleBackground(horizontalPaging, backgroundColor)) - setTextColor(scrollHandleTextColor) + PdfPageIndicator(context) { currentShowPageIndicator }.apply { + updateColors( + context.scrollHandleBackground(horizontalPaging, colors.primary.toArgb()), + colors.onPrimary.toArgb(), + ) pageIndicator = this } ) @@ -179,11 +181,20 @@ private class ResizablePdfView(context: Context) : PDFView(context, null) { } } -@Composable -private fun pdfHighlightColor(nightMode: Boolean) = if (nightMode == isSurfaceNotLitWell()) { - SimpleTheme.colorScheme.primary -} else { - SimpleTheme.colorScheme.inversePrimary +private class PdfPageIndicator( + context: Context, + private val shouldShow: () -> Boolean, +) : DefaultScrollHandle(context) { + override fun show() { + if (shouldShow()) super.show() + } + + fun updateColors(background: Drawable?, textColor: Int) { + setPageHandleBackground(background) + setTextColor(textColor) + pageIndicator?.background = background + pageIndicator?.setTextColor(textColor) + } } private fun Context.scrollHandleBackground(horizontalPaging: Boolean, color: Int): Drawable? { @@ -202,15 +213,13 @@ private fun PDFView.Configurator.readingMode(horizontalPaging: Boolean): PDFView .autoSpacing(horizontalPaging) .pageSnap(horizontalPaging) .pageFling(horizontalPaging) - .pageSeparatorSpacing(if (horizontalPaging) 0 else PDF_PAGE_SPACING_DP) + .pageSeparatorSpacing(PDF_PAGE_SPACING_DP) .apply { if (horizontalPaging) zoom(1f, PDF_MID_ZOOM, PDF_MAX_ZOOM) } } private const val PDF_MID_ZOOM = 1.75f private const val PDF_MAX_ZOOM = 5f private const val PDF_PAGE_SPACING_DP = 8 -private const val MATCH_HIGHLIGHT_ALPHA = 0.18f -private const val CURRENT_MATCH_HIGHLIGHT_ALPHA = 0.4f private fun PDFView.showSearchMatch(match: PdfTextMatch) { if (width == 0 || height == 0) return diff --git a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt index 051f02b..33d9268 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt @@ -249,6 +249,7 @@ private fun WebDocument( val accentColor = SimpleTheme.colorScheme.primary val outlineColor = SimpleTheme.colorScheme.outlineVariant val codeColor = SimpleTheme.colorScheme.surfaceVariant + val searchColors = documentSearchColors() val configuredFontType = context.baseConfig.fontType val configuredFontName = context.baseConfig.fontName val webFont = remember(context, configuredFontType, configuredFontName) { @@ -261,6 +262,7 @@ private fun WebDocument( accentColor, outlineColor, codeColor, + searchColors, webFont, ) { buildWebPage( @@ -270,6 +272,7 @@ private fun WebDocument( accentColor = accentColor, outlineColor = outlineColor, codeColor = codeColor, + searchColors = searchColors, webFont = webFont, ) } @@ -441,6 +444,7 @@ private fun buildWebPage( accentColor: Color, outlineColor: Color, codeColor: Color, + searchColors: DocumentSearchColors, webFont: WebDocumentFont, ): String { val customFontFace = webFont.file?.let { @@ -473,6 +477,14 @@ private fun buildWebPage( line-height: 1.55; overflow-wrap: anywhere; } + ::search-text { + background-color: ${searchColors.match.cssColor()}; + color: ${textColor.cssColor()}; + } + ::search-text:current { + background-color: ${searchColors.currentMatch.cssColor()}; + color: ${textColor.cssColor()}; + } a { color: ${accentColor.cssColor()}; } img { max-width: 100%; height: auto; } table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; } @@ -513,7 +525,11 @@ private fun resolveWebDocumentFont( } private fun Color.cssColor(): String { - return String.format(Locale.ROOT, "#%06X", toArgb() and 0xFFFFFF) + val argb = toArgb() + return String.format( + Locale.ROOT, "rgba(%d, %d, %d, %.3f)", + AndroidColor.red(argb), AndroidColor.green(argb), AndroidColor.blue(argb), alpha, + ) } private data class WebDocumentFont( diff --git a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt index 2577746..1bab17d 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentScreen.kt @@ -513,8 +513,9 @@ private fun rememberSearchHighlighting( searchMatches: List, currentSearchIndex: Int, ): OutputTransformation? { - val matchColor = SimpleTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) - val currentMatchColor = SimpleTheme.colorScheme.primaryContainer + val searchColors = documentSearchColors() + val matchColor = searchColors.match + val currentMatchColor = searchColors.currentMatch return if (shouldHighlightTextMatches(searchMatches.size)) { remember( searchMatches, From 262f1995b0e8036c2b863b87616f10a998f1b21e Mon Sep 17 00:00:00 2001 From: Naveen Singh Date: Mon, 5 Oct 2026 21:13:23 +0530 Subject: [PATCH 6/6] fix: use proper method --- .../main/java/org/fossify/documents/ui/screens/PdfPageView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt index c39da5b..919f85d 100644 --- a/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt +++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt @@ -112,7 +112,7 @@ internal fun PdfPageView( .onSelectionChange(onSelectionChange) .onSelectionAction { text -> val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.primaryClip = ClipData.newPlainText(title, text) + clipboard.setPrimaryClip(ClipData.newPlainText(title, text)) clearTextSelection() } .onDrawAll { canvas, width, height, page ->