Skip to content

Add page persistence#598

Open
ggtlvkma356 wants to merge 3 commits intoGrapheneOS:mainfrom
ggtlvkma356:add-page-persistence
Open

Add page persistence#598
ggtlvkma356 wants to merge 3 commits intoGrapheneOS:mainfrom
ggtlvkma356:add-page-persistence

Conversation

@ggtlvkma356
Copy link
Copy Markdown

Added the following functionalities:

  • Re-open last opened document when cold starting PdfViewer
  • Automatically jump to last opened page of the corresponding document
    • Last page is remembered based on file hash, thus will survive move/copy
    • Up to 50 pairs of last opened page of their corresponding document are saved
  • A simple setting dialog to toggle this behavior on or off

There is an edge case when a document is opened from ACTION_VIEW (e.g. from Files) and then the app is killed, then it cannot re-open last document. This is due to a security limitation of Android system.

closes #280

@ggtlvkma356 ggtlvkma356 force-pushed the add-page-persistence branch 2 times, most recently from 8bd6c4b to 128b255 Compare March 6, 2026 23:48

class SettingsDialog : DialogFragment() {

private var _binding: DialogSettingsBinding? = null
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property is redundant, it's used only inside onCreate()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the property.

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DialogSettingsBinding.inflate(layoutInflater)

val prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreferenceHelper.PREF_NAME

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

object FileHash {

private const val HASH_BUFFER_SIZE = 8192
private const val MAX_HASH_BYTES = 16 * 1024 // 16KB
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why size limit is this low? Are first 16 KiB of a PDF file guaranteed to be distinct for all PDF files?

Copy link
Copy Markdown
Author

@ggtlvkma356 ggtlvkma356 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expand it to 10 MB. I looked it up and on modern mobile devices this shall take at most 1 second, which sounds fine to me.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hash is removed. It now uses fingerprint from pdf.js directly, which reads ID from a pdf file first, and if that fails, computes MD5 hash for the first 1KB.

totalBytesRead += bytesToHash
}

digest.digest().joinToString("") { "%02x".format(it) }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to use kotlin.text.HexFormat for such conversions, the current approach is very inefficient

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.


hashCalculationDeferred = viewModelScope.async {
val hash = FileHash.calculateFileHash(uri, contentResolver)
currentFileHash = hash
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a race condition: currentFileHash write is not guaranteed to be cancelled when hashCalculationDeferred is cancelled

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the assignment to when the Deferred is actually used.

return pdfStateFlow.first().filePagePositions[fileHash]
}

suspend fun clearAll() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this function unused?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

put(key, jsonObject.getInt(key))
}
}
} catch (e: Exception) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conversion should never fail, it's a bug if it does fail. We shouldn't hide potential bugs by swallowing exceptions

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the try..catch block.


fun hasUriPermission(uri: Uri?): Boolean {
return contentResolver.persistedUriPermissions
.stream()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you using Java stream in Kotlin code?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was moved from the Java main class. I changed it to use Kotlin any.

Uri uri = Uri.parse(state.getLastOpenedUri());
if (viewModel.hasUriPermission(uri)) {
try {
getContentResolver().openInputStream(uri).close();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening a Uri is an expensive operation, especially for some third-party content providers. Content resolver result should be reused

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to just rely on the permission check, so no need to open the actual uri.

private fun releaseUriPermissionIfHeld(uri: Uri) {
try {
// Find if we have persisted permission for this URI
val persistedPermissions = contentResolver.persistedUriPermissions
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unnecessary and slow to obtain all persisted Uri permissions.

This entire code block can be replaced with

try {
    contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)   
} catch (e: SecurityException) {
    Log.w(TAG, "permission release failed", e)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

@ggtlvkma356 ggtlvkma356 force-pushed the add-page-persistence branch 6 times, most recently from dd73508 to f4be0c6 Compare March 10, 2026 22:09
Comment on lines +18 to +20
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "pdf_preferences"
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also handle data format corruption here (corruptionHandler) to explicitly replace it with empty preferences. We can keep the .catch in the flow for other errors like actual IO issues during reading

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is a good idea. I added a ReplaceFileCorruptionHandler to clear the preferences when there is a corruption. The exception handler for reading can be kept the same for IOException.

 - Saving last opened file uri
 - Saving last opened page for each file
 - Hashing files (first 64 KB) to resume regardless of file path
 - Add setting to toggle resume behavior on/off
 - Bug fix: mNumPages not cleared when opening new document
@ggtlvkma356 ggtlvkma356 force-pushed the add-page-persistence branch from f4be0c6 to ffc40ce Compare March 11, 2026 20:18
@ggtlvkma356 ggtlvkma356 requested a review from muhomorr March 13, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PDF Page Persistence

4 participants