From ffc40ce4d424ace82c605a531f878c46a31d3787 Mon Sep 17 00:00:00 2001 From: ggtlvkma356 <265052551+ggtlvkma356@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:09:11 +0100 Subject: [PATCH] Add open document and page persistence - 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 --- app/build.gradle.kts | 1 + .../app/grapheneos/pdfviewer/PdfViewer.java | 53 ++++++ .../grapheneos/pdfviewer/PreferenceHelper.kt | 15 ++ .../pdfviewer/fragment/SettingsDialog.kt | 32 ++++ .../preferences/PdfPreferencesRepository.kt | 112 +++++++++++++ .../pdfviewer/viewModel/PdfViewModel.kt | 139 +++++++++++++++- .../main/res/drawable/ic_settings_24dp.xml | 9 ++ app/src/main/res/layout/dialog_settings.xml | 15 ++ app/src/main/res/menu/pdf_viewer.xml | 6 + app/src/main/res/values/strings.xml | 7 +- gradle/verification-metadata.xml | 151 ++++++++++++++++++ viewer/js/index.js | 3 + 12 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/grapheneos/pdfviewer/PreferenceHelper.kt create mode 100644 app/src/main/java/app/grapheneos/pdfviewer/fragment/SettingsDialog.kt create mode 100644 app/src/main/java/app/grapheneos/pdfviewer/preferences/PdfPreferencesRepository.kt create mode 100644 app/src/main/res/drawable/ic_settings_24dp.xml create mode 100644 app/src/main/res/layout/dialog_settings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d2f3b74f..f10d9804e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.fragment:fragment-ktx:1.8.9") implementation("com.google.android.material:material:1.13.0") + implementation("androidx.datastore:datastore-preferences:1.2.1") } fun getCommand(command: String, winExt: String = "cmd"): String { diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 936f70851..40b01d33c 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -51,9 +51,11 @@ import app.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment; import app.grapheneos.pdfviewer.fragment.JumpToPageFragment; import app.grapheneos.pdfviewer.fragment.PasswordPromptFragment; +import app.grapheneos.pdfviewer.fragment.SettingsDialog; import app.grapheneos.pdfviewer.ktx.ViewKt; import app.grapheneos.pdfviewer.loader.DocumentPropertiesAsyncTaskLoader; import app.grapheneos.pdfviewer.outline.OutlineFragment; +import app.grapheneos.pdfviewer.preferences.PdfPreferencesRepository; import app.grapheneos.pdfviewer.viewModel.PdfViewModel; public class PdfViewer extends AppCompatActivity implements LoaderManager.LoaderCallbacks> { @@ -62,6 +64,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private static final String STATE_WEBVIEW_CRASHED = "webview_crashed"; private static final String STATE_URI = "uri"; private static final String STATE_PAGE = "page"; + private static final String STATE_NUM_PAGES = "numPages"; private static final String STATE_ZOOM_RATIO = "zoomRatio"; private static final String STATE_DOCUMENT_ORIENTATION_DEGREES = "documentOrientationDegrees"; private static final String STATE_ENCRYPTED_DOCUMENT_PASSWORD = "encrypted_document_password"; @@ -146,9 +149,13 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader if (resultData != null) { mUri = result.getData().getData(); mPage = 1; + mNumPages = 0; mDocumentProperties = null; mEncryptedDocumentPassword = ""; viewModel.clearOutline(); + if (mUri != null && PreferenceHelper.INSTANCE.isResumeLastDocumentEnabled(this)) { + viewModel.prepareNewPdf(mUri, mPage); + } loadPdf(); invalidateOptionsMenu(); } @@ -229,6 +236,16 @@ public void setNumPages(int numPages) { runOnUiThread(PdfViewer.this::invalidateOptionsMenu); } + @JavascriptInterface + public void setFingerprint(String fingerprint) { + viewModel.setCurrentFingerprint(fingerprint); + Integer savedPage = viewModel.getPageByFingerprintBlocking(); + + if (savedPage != null) { + runOnUiThread(() -> onJumpToPageInDocument(savedPage)); + } + } + @JavascriptInterface public void setDocumentProperties(final String properties) { if (mDocumentProperties != null) { @@ -486,6 +503,7 @@ public void onZoomEnd() { } mUri = intent.getData(); mPage = 1; + mNumPages = 0; } if (savedInstanceState != null) { @@ -498,9 +516,31 @@ public void onZoomEnd() { mUri = uri; } mPage = savedInstanceState.getInt(STATE_PAGE); + mNumPages = savedInstanceState.getInt(STATE_NUM_PAGES); mZoomRatio = savedInstanceState.getFloat(STATE_ZOOM_RATIO); mDocumentOrientationDegrees = savedInstanceState.getInt(STATE_DOCUMENT_ORIENTATION_DEGREES); mEncryptedDocumentPassword = savedInstanceState.getString(STATE_ENCRYPTED_DOCUMENT_PASSWORD); + } else { + if (mUri != null && PreferenceHelper.INSTANCE.isResumeLastDocumentEnabled(this)) { + viewModel.prepareNewPdf(mUri, mPage); + } else { + PdfPreferencesRepository.PdfState state = + viewModel.maybeLoadPdfStateBlocking(); + + if (state != null && state.getLastOpenedUri() != null) { + Uri uri = Uri.parse(state.getLastOpenedUri()); + if (viewModel.hasUriPermission(uri)) { + mUri = uri; + mPage = state.getLastOpenedPage(); + mNumPages = 0; + } else { + viewModel.clearLastOpened(); + Toast.makeText(this, + R.string.msg_previous_document_unavailable, + Toast.LENGTH_LONG).show(); + } + } + } } binding.webviewAlertReload.setOnClickListener(v -> { @@ -526,6 +566,15 @@ private void purgeWebView() { binding.webview.destroy(); } + @Override + protected void onStop() { + super.onStop(); + + if (mUri != null) { + viewModel.savePdfStateBlocking(mUri.toString(), mPage, true); + } + } + @Override protected void onDestroy() { super.onDestroy(); @@ -696,6 +745,7 @@ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { savedInstanceState.putBoolean(STATE_WEBVIEW_CRASHED, webViewCrashed); savedInstanceState.putParcelable(STATE_URI, mUri); savedInstanceState.putInt(STATE_PAGE, mPage); + savedInstanceState.putInt(STATE_NUM_PAGES, mNumPages); savedInstanceState.putFloat(STATE_ZOOM_RATIO, mZoomRatio); savedInstanceState.putInt(STATE_DOCUMENT_ORIENTATION_DEGREES, mDocumentOrientationDegrees); savedInstanceState.putString(STATE_ENCRYPTED_DOCUMENT_PASSWORD, mEncryptedDocumentPassword); @@ -827,6 +877,9 @@ public boolean onOptionsItemSelected(MenuItem item) { } else if (itemId == R.id.debug_action_crash_webview) { binding.webview.loadUrl("chrome://crash"); return true; + } else if (itemId == R.id.action_settings) { + new SettingsDialog().show(getSupportFragmentManager(), "settings"); + return true; } return super.onOptionsItemSelected(item); diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PreferenceHelper.kt b/app/src/main/java/app/grapheneos/pdfviewer/PreferenceHelper.kt new file mode 100644 index 000000000..b0cf57ddc --- /dev/null +++ b/app/src/main/java/app/grapheneos/pdfviewer/PreferenceHelper.kt @@ -0,0 +1,15 @@ +package app.grapheneos.pdfviewer + + +import android.content.Context + +object PreferenceHelper { + + const val PREF_NAME = "app_prefs" + const val KEY_RESUME_LAST_DOCUMENT = "resume_last_document" + + fun isResumeLastDocumentEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_RESUME_LAST_DOCUMENT, true) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/pdfviewer/fragment/SettingsDialog.kt b/app/src/main/java/app/grapheneos/pdfviewer/fragment/SettingsDialog.kt new file mode 100644 index 000000000..be0b57b0f --- /dev/null +++ b/app/src/main/java/app/grapheneos/pdfviewer/fragment/SettingsDialog.kt @@ -0,0 +1,32 @@ +package app.grapheneos.pdfviewer.fragment + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import androidx.core.content.edit +import androidx.fragment.app.DialogFragment +import app.grapheneos.pdfviewer.PreferenceHelper +import app.grapheneos.pdfviewer.R +import app.grapheneos.pdfviewer.databinding.DialogSettingsBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class SettingsDialog : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogSettingsBinding.inflate(layoutInflater) + + val prefs = requireContext().getSharedPreferences(PreferenceHelper.PREF_NAME, Context.MODE_PRIVATE) + binding.switchResumeDocument.isChecked = + prefs.getBoolean(PreferenceHelper.KEY_RESUME_LAST_DOCUMENT, true) + binding.switchResumeDocument.setOnCheckedChangeListener { _, isChecked -> + prefs.edit { + putBoolean(PreferenceHelper.KEY_RESUME_LAST_DOCUMENT, isChecked) + } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.settings) + .setView(binding.root) + .create() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/pdfviewer/preferences/PdfPreferencesRepository.kt b/app/src/main/java/app/grapheneos/pdfviewer/preferences/PdfPreferencesRepository.kt new file mode 100644 index 000000000..b8666987e --- /dev/null +++ b/app/src/main/java/app/grapheneos/pdfviewer/preferences/PdfPreferencesRepository.kt @@ -0,0 +1,112 @@ +package app.grapheneos.pdfviewer.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.IOException +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.json.JSONObject + +private val Context.dataStore: DataStore by preferencesDataStore( + name = "pdf_preferences", + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() } +) + +class PdfPreferencesRepository (private val context: Context) { + + private object PreferencesKeys { + val LAST_OPENED_URI = stringPreferencesKey("last_opened_uri") + val LAST_OPENED_PAGE = intPreferencesKey("last_opened_page") + val FILE_PAGE_POSITIONS = stringPreferencesKey("file_page_positions") + } + + companion object { + private const val MAX_FILE_HISTORY = 50 + } + + data class PdfState( + val lastOpenedUri: String? = null, + val lastOpenedPage: Int = 1, + val filePagePositions: Map = emptyMap() + ) + + val pdfStateFlow: Flow = context.dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + PdfState( + lastOpenedUri = preferences[PreferencesKeys.LAST_OPENED_URI], + lastOpenedPage = preferences[PreferencesKeys.LAST_OPENED_PAGE] ?: 1, + filePagePositions = parseFilePagePositions( + preferences[PreferencesKeys.FILE_PAGE_POSITIONS] + ) + ) + } + + suspend fun saveLastOpened(uri: String?, page: Int) { + context.dataStore.edit { preferences -> + if (uri != null) { + preferences[PreferencesKeys.LAST_OPENED_URI] = uri + } else { + preferences.remove(PreferencesKeys.LAST_OPENED_URI) + } + preferences[PreferencesKeys.LAST_OPENED_PAGE] = page + } + } + + suspend fun clearLastOpened() { + context.dataStore.edit { preferences -> + preferences.remove(PreferencesKeys.LAST_OPENED_URI) + preferences.remove(PreferencesKeys.LAST_OPENED_PAGE) + } + } + + suspend fun updatePagePosition(fileHash: String, page: Int) { + context.dataStore.edit { preferences -> + val currentMap = parseFilePagePositions( + preferences[PreferencesKeys.FILE_PAGE_POSITIONS] + ).toMutableMap() + + currentMap[fileHash] = page + + if (currentMap.size > MAX_FILE_HISTORY) { + val toRemove = currentMap.keys.take(currentMap.size - MAX_FILE_HISTORY) + toRemove.forEach { currentMap.remove(it) } + } + + preferences[PreferencesKeys.FILE_PAGE_POSITIONS] = serializeFilePagePositions(currentMap) + } + } + + suspend fun getPageForFile(fileHash: String): Int? { + return pdfStateFlow.first().filePagePositions[fileHash] + } + + private fun parseFilePagePositions(json: String?): Map { + if (json.isNullOrEmpty()) return emptyMap() + val jsonObject = JSONObject(json) + return buildMap { + jsonObject.keys().forEach { key -> + put(key, jsonObject.getInt(key)) + } + } + } + + private fun serializeFilePagePositions(map: Map): String { + return JSONObject(map).toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index fd8b05563..d84f9d6f4 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -1,15 +1,30 @@ package app.grapheneos.pdfviewer.viewModel +import android.app.Application +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.net.toUri +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.grapheneos.pdfviewer.PreferenceHelper import app.grapheneos.pdfviewer.outline.OutlineNode +import app.grapheneos.pdfviewer.preferences.PdfPreferencesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking -class PdfViewModel : ViewModel() { +class PdfViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private const val TAG = "PdfViewModel" + } enum class PasswordStatus { MissingPassword, @@ -34,6 +49,10 @@ class PdfViewModel : ViewModel() { private val scope = CoroutineScope(Dispatchers.IO) + private val repository = PdfPreferencesRepository(application) + private val contentResolver: ContentResolver = application.contentResolver + var currentFingerprint: String? = null + override fun onCleared() { super.onCleared() scope.cancel() @@ -90,4 +109,120 @@ class PdfViewModel : ViewModel() { outline.postValue(if (hasOutline) OutlineStatus.Available else OutlineStatus.NoOutline) } } + + /** + * Load initial PDF state if preference is enabled + */ + fun maybeLoadPdfStateBlocking(): PdfPreferencesRepository.PdfState? { + return runBlocking { + if (PreferenceHelper.isResumeLastDocumentEnabled(getApplication())) { + repository.pdfStateFlow.first() + } else { + null + } + } + } + + /** + * Get saved page by fingerprint + * + * @return saved page number, or null if no fingerprint or no saved page + */ + fun getPageByFingerprintBlocking(): Int? { + return runBlocking { + currentFingerprint?.let { repository.getPageForFile(it) } + } + } + + /** + * Save PDF state + * + * @param uriString Document URI string + * @param page Current page + * @param includeHashMapping Whether to also save hash->page mapping + */ + fun savePdfState(uriString: String, page: Int, includeHashMapping: Boolean) { + viewModelScope.launch { + savePdfStateCommon(uriString, page, includeHashMapping) + } + } + + /** + * Save PDF state (blocking) + * Use in onStop to ensure completion before process death + * + * @param uriString Document URI string + * @param page Current page + * @param includeHashMapping Whether to also save hash->page mapping + */ + fun savePdfStateBlocking(uriString: String, page: Int, includeHashMapping: Boolean) { + runBlocking { + savePdfStateCommon(uriString, page, includeHashMapping) + } + } + + private suspend fun savePdfStateCommon(uriString: String, page: Int, includeHashMapping: Boolean) { + val oldState = repository.pdfStateFlow.first() + val oldUri = oldState.lastOpenedUri + + repository.saveLastOpened(uriString, page) + + // Release old permission if it's different from new URI + if (oldUri != null && oldUri != uriString) { + releaseUriPermissionIfHeld(oldUri.toUri()) + } + + if (includeHashMapping && currentFingerprint != null) { + repository.updatePagePosition(currentFingerprint!!, page) + } + } + + fun clearLastOpened() { + viewModelScope.launch { + val state = repository.pdfStateFlow.first() + val currentUri = state.lastOpenedUri + + repository.clearLastOpened() + + if (currentUri != null) { + releaseUriPermissionIfHeld(currentUri.toUri()) + } + } + } + + fun prepareNewPdf(uri: Uri, page: Int) { + if (takePersistableUriPermission(uri)) { + savePdfState(uri.toString(), page, false) + } + } + + private fun releaseUriPermissionIfHeld(uri: Uri) { + 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) + } + } + + private fun takePersistableUriPermission(uri: Uri): Boolean { + try { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + return true + } catch (_: SecurityException) { + return false + } + } + + fun hasUriPermission(uri: Uri?): Boolean { + return contentResolver.persistedUriPermissions + .any { permission -> + permission.uri == uri && permission.isReadPermission + } + } } diff --git a/app/src/main/res/drawable/ic_settings_24dp.xml b/app/src/main/res/drawable/ic_settings_24dp.xml new file mode 100644 index 000000000..854d5ca31 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml new file mode 100644 index 000000000..165793e9a --- /dev/null +++ b/app/src/main/res/layout/dialog_settings.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/pdf_viewer.xml b/app/src/main/res/menu/pdf_viewer.xml index 16cd9b289..3ceae045f 100644 --- a/app/src/main/res/menu/pdf_viewer.xml +++ b/app/src/main/res/menu/pdf_viewer.xml @@ -77,4 +77,10 @@ android:title="@string/action_view_document_properties" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef05b47df..986dd5ecd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ PDF Viewer - Previous page Next page Open document @@ -14,6 +13,7 @@ Save as Outline Properties + Settings Close View nested outline entries @@ -57,4 +57,9 @@ Pages Unknown + + Settings + Resume last document + + Previous document is no longer accessible. Please reopen it. diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1d23b5f41..4196706c2 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3,6 +3,11 @@ true false + + + + + @@ -41,6 +46,11 @@ + + + + + @@ -62,6 +72,14 @@ + + + + + + + + @@ -258,6 +276,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1586,6 +1685,19 @@ + + + + + + + + + + + + + @@ -2719,6 +2831,14 @@ + + + + + + + + @@ -2801,6 +2921,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/viewer/js/index.js b/viewer/js/index.js index 3eed8feec..0be04c111 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -420,6 +420,9 @@ globalThis.loadDocument = function () { channel.onLoaded(); pdfDoc = newDoc; channel.setNumPages(pdfDoc.numPages); + // In pdf.js, they only use the original ID to track view history + // See https://github.com/mozilla/pdf.js/blob/eb159abd6a053d98fd0dfe7976c08f8d09618a51/web/app.js#L1486 + channel.setFingerprint(pdfDoc.fingerprints[0]); pdfDoc.getMetadata().then(function (data) { channel.setDocumentProperties(JSON.stringify(data.info)); }).catch(function (error) {