diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b1c5ed..b81b323 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, 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])
+- 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..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
@@ -7,22 +5,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 +32,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 +47,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 +93,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 +116,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 +136,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 +187,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 +268,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..6c1c14f 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,42 @@ internal class DocumentLocationResolver(
}
}
+ fun resolvePickerLocation(uri: Uri): Uri? {
+ if (DocumentsContract.isDocumentUri(appContext, uri) || DocumentsContract.isTreeUri(uri)) {
+ return uri
+ }
+ val documentUri = if (uri.authority == MediaStore.AUTHORITY) {
+ runCatching { MediaStore.getDocumentUri(appContext, uri) }.getOrNull()
+ } else {
+ 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)
+ ?.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)) {
+ DocumentsContract.buildDocumentUri(
+ EXTERNAL_STORAGE_AUTHORITY,
+ "$volumeId:${folder.relativeTo(root).invariantSeparatorsPath}",
+ )
+ } else {
+ null
+ }
+ }.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/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/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/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/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/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 4098645..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
@@ -14,7 +11,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
@@ -22,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
@@ -175,33 +170,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 +214,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..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
@@ -48,7 +47,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 +199,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..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
@@ -7,11 +6,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 +141,6 @@ private fun DocumentsSelectionOverflowMenu(
expanded = false
actions.onOpenWith(selectedDocument)
},
- leadingIcon = {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.OpenInNew,
- contentDescription = null,
- )
- },
)
}
if (canRemove) {
@@ -159,12 +150,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/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 813b563..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,5 +1,4 @@
-@file:OptIn(ExperimentalMaterial3Api::class)
-@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber")
+@file:Suppress("CyclomaticComplexMethod")
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..919f85d
--- /dev/null
+++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfPageView.kt
@@ -0,0 +1,261 @@
+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.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 colors = SimpleTheme.colorScheme
+ 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(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)
+ 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(
+ PdfPageIndicator(context) { currentShowPageIndicator }.apply {
+ updateColors(
+ context.scrollHandleBackground(horizontalPaging, colors.primary.toArgb()),
+ colors.onPrimary.toArgb(),
+ )
+ 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)
+ }
+}
+
+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? {
+ 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(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 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..8af8c34
--- /dev/null
+++ b/app/src/main/java/org/fossify/documents/ui/screens/PdfViewerChrome.kt
@@ -0,0 +1,249 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+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..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
@@ -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/StructuredDocumentScreen.kt b/app/src/main/java/org/fossify/documents/ui/screens/StructuredDocumentScreen.kt
index f7a2a3b..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
@@ -1,5 +1,5 @@
@file:OptIn(ExperimentalMaterial3Api::class)
-@file:Suppress("FunctionNaming", "LongMethod", "MagicNumber")
+@file:Suppress("MagicNumber")
package org.fossify.documents.ui.screens
@@ -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
@@ -192,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) {
@@ -204,6 +262,7 @@ private fun WebDocument(
accentColor,
outlineColor,
codeColor,
+ searchColors,
webFont,
) {
buildWebPage(
@@ -213,6 +272,7 @@ private fun WebDocument(
accentColor = accentColor,
outlineColor = outlineColor,
codeColor = codeColor,
+ searchColors = searchColors,
webFont = webFont,
)
}
@@ -232,6 +292,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 +325,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(),
@@ -377,6 +444,7 @@ private fun buildWebPage(
accentColor: Color,
outlineColor: Color,
codeColor: Color,
+ searchColors: DocumentSearchColors,
webFont: WebDocumentFont,
): String {
val customFontFace = webFont.file?.let {
@@ -409,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; }
@@ -449,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/TextDocumentControls.kt b/app/src/main/java/org/fossify/documents/ui/screens/TextDocumentControls.kt
index e2126f6..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
@@ -15,11 +12,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
@@ -33,12 +26,10 @@ 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
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 +109,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 +129,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..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
@@ -1,5 +1,4 @@
@file:OptIn(ExperimentalMaterial3Api::class)
-@file:Suppress("FunctionNaming", "LongMethod", "LongParameterList", "MagicNumber")
package org.fossify.documents.ui.screens
@@ -17,7 +16,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 +41,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 +68,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 +145,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 +173,7 @@ internal fun TextDocumentScreen(
TextDocumentContent(
uiState = uiState,
editorState = editorState,
+ onOpenForEditing = onOpenForEditing,
onPreviewChange = onPreviewChange,
searchMatches = searchMatches,
currentSearchIndex = normalizedSearchIndex,
@@ -200,6 +188,8 @@ private fun TextDocumentTopBar(
uiState: TextDocumentUiState,
onBack: () -> Unit,
onSave: () -> Unit,
+ onSaveCopy: () -> Unit,
+ onOpenForEditing: () -> Unit,
onOpenWith: () -> Unit,
searchActive: Boolean,
searchQuery: String,
@@ -294,6 +284,10 @@ private fun TextDocumentTopBar(
onTextZoomChange = onTextZoomChange,
onResetTextZoom = onResetTextZoom,
onOpenWith = onOpenWith,
+ onSaveCopy = onSaveCopy,
+ canSaveCopy = uiState.canSaveCopy && !uiState.isSaving,
+ isReadOnly = uiState.isReadOnly,
+ onOpenForEditing = onOpenForEditing,
)
}
},
@@ -308,6 +302,7 @@ private fun TextDocumentTopBar(
private fun TextDocumentContent(
uiState: TextDocumentUiState,
editorState: TextFieldState,
+ onOpenForEditing: () -> Unit,
onPreviewChange: (Boolean) -> Unit,
searchMatches: List,
currentSearchIndex: Int,
@@ -327,6 +322,7 @@ private fun TextDocumentContent(
else -> LoadedTextDocumentContent(
uiState = uiState,
editorState = editorState,
+ onOpenForEditing = onOpenForEditing,
onPreviewChange = onPreviewChange,
searchMatches = searchMatches,
currentSearchIndex = currentSearchIndex,
@@ -341,6 +337,7 @@ private fun TextDocumentContent(
private fun ColumnScope.LoadedTextDocumentContent(
uiState: TextDocumentUiState,
editorState: TextFieldState,
+ onOpenForEditing: () -> Unit,
onPreviewChange: (Boolean) -> Unit,
searchMatches: List,
currentSearchIndex: Int,
@@ -354,15 +351,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 +436,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 +476,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 +508,43 @@ private fun TextEditor(
)
}
+@Composable
+private fun rememberSearchHighlighting(
+ searchMatches: List,
+ currentSearchIndex: Int,
+): OutputTransformation? {
+ val searchColors = documentSearchColors()
+ val matchColor = searchColors.match
+ val currentMatchColor = searchColors.currentMatch
+ 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..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,22 +1,23 @@
-@file:Suppress("FunctionNaming", "MagicNumber")
-
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 +31,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 +78,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 +91,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/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) },
+ )
+ }
+}
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/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..f82d6c0 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,14 +21,12 @@ 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
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
@@ -48,9 +45,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" }