Skip to content

Commit ffc40ce

Browse files
committed
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
1 parent 92717fe commit ffc40ce

12 files changed

Lines changed: 540 additions & 3 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ dependencies {
9292
implementation("androidx.core:core-ktx:1.17.0")
9393
implementation("androidx.fragment:fragment-ktx:1.8.9")
9494
implementation("com.google.android.material:material:1.13.0")
95+
implementation("androidx.datastore:datastore-preferences:1.2.1")
9596
}
9697

9798
fun getCommand(command: String, winExt: String = "cmd"): String {

app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@
5151
import app.grapheneos.pdfviewer.fragment.DocumentPropertiesFragment;
5252
import app.grapheneos.pdfviewer.fragment.JumpToPageFragment;
5353
import app.grapheneos.pdfviewer.fragment.PasswordPromptFragment;
54+
import app.grapheneos.pdfviewer.fragment.SettingsDialog;
5455
import app.grapheneos.pdfviewer.ktx.ViewKt;
5556
import app.grapheneos.pdfviewer.loader.DocumentPropertiesAsyncTaskLoader;
5657
import app.grapheneos.pdfviewer.outline.OutlineFragment;
58+
import app.grapheneos.pdfviewer.preferences.PdfPreferencesRepository;
5759
import app.grapheneos.pdfviewer.viewModel.PdfViewModel;
5860

5961
public class PdfViewer extends AppCompatActivity implements LoaderManager.LoaderCallbacks<List<CharSequence>> {
@@ -62,6 +64,7 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader
6264
private static final String STATE_WEBVIEW_CRASHED = "webview_crashed";
6365
private static final String STATE_URI = "uri";
6466
private static final String STATE_PAGE = "page";
67+
private static final String STATE_NUM_PAGES = "numPages";
6568
private static final String STATE_ZOOM_RATIO = "zoomRatio";
6669
private static final String STATE_DOCUMENT_ORIENTATION_DEGREES = "documentOrientationDegrees";
6770
private static final String STATE_ENCRYPTED_DOCUMENT_PASSWORD = "encrypted_document_password";
@@ -146,9 +149,13 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader
146149
if (resultData != null) {
147150
mUri = result.getData().getData();
148151
mPage = 1;
152+
mNumPages = 0;
149153
mDocumentProperties = null;
150154
mEncryptedDocumentPassword = "";
151155
viewModel.clearOutline();
156+
if (mUri != null && PreferenceHelper.INSTANCE.isResumeLastDocumentEnabled(this)) {
157+
viewModel.prepareNewPdf(mUri, mPage);
158+
}
152159
loadPdf();
153160
invalidateOptionsMenu();
154161
}
@@ -229,6 +236,16 @@ public void setNumPages(int numPages) {
229236
runOnUiThread(PdfViewer.this::invalidateOptionsMenu);
230237
}
231238

239+
@JavascriptInterface
240+
public void setFingerprint(String fingerprint) {
241+
viewModel.setCurrentFingerprint(fingerprint);
242+
Integer savedPage = viewModel.getPageByFingerprintBlocking();
243+
244+
if (savedPage != null) {
245+
runOnUiThread(() -> onJumpToPageInDocument(savedPage));
246+
}
247+
}
248+
232249
@JavascriptInterface
233250
public void setDocumentProperties(final String properties) {
234251
if (mDocumentProperties != null) {
@@ -486,6 +503,7 @@ public void onZoomEnd() {
486503
}
487504
mUri = intent.getData();
488505
mPage = 1;
506+
mNumPages = 0;
489507
}
490508

491509
if (savedInstanceState != null) {
@@ -498,9 +516,31 @@ public void onZoomEnd() {
498516
mUri = uri;
499517
}
500518
mPage = savedInstanceState.getInt(STATE_PAGE);
519+
mNumPages = savedInstanceState.getInt(STATE_NUM_PAGES);
501520
mZoomRatio = savedInstanceState.getFloat(STATE_ZOOM_RATIO);
502521
mDocumentOrientationDegrees = savedInstanceState.getInt(STATE_DOCUMENT_ORIENTATION_DEGREES);
503522
mEncryptedDocumentPassword = savedInstanceState.getString(STATE_ENCRYPTED_DOCUMENT_PASSWORD);
523+
} else {
524+
if (mUri != null && PreferenceHelper.INSTANCE.isResumeLastDocumentEnabled(this)) {
525+
viewModel.prepareNewPdf(mUri, mPage);
526+
} else {
527+
PdfPreferencesRepository.PdfState state =
528+
viewModel.maybeLoadPdfStateBlocking();
529+
530+
if (state != null && state.getLastOpenedUri() != null) {
531+
Uri uri = Uri.parse(state.getLastOpenedUri());
532+
if (viewModel.hasUriPermission(uri)) {
533+
mUri = uri;
534+
mPage = state.getLastOpenedPage();
535+
mNumPages = 0;
536+
} else {
537+
viewModel.clearLastOpened();
538+
Toast.makeText(this,
539+
R.string.msg_previous_document_unavailable,
540+
Toast.LENGTH_LONG).show();
541+
}
542+
}
543+
}
504544
}
505545

506546
binding.webviewAlertReload.setOnClickListener(v -> {
@@ -526,6 +566,15 @@ private void purgeWebView() {
526566
binding.webview.destroy();
527567
}
528568

569+
@Override
570+
protected void onStop() {
571+
super.onStop();
572+
573+
if (mUri != null) {
574+
viewModel.savePdfStateBlocking(mUri.toString(), mPage, true);
575+
}
576+
}
577+
529578
@Override
530579
protected void onDestroy() {
531580
super.onDestroy();
@@ -696,6 +745,7 @@ public void onSaveInstanceState(@NonNull Bundle savedInstanceState) {
696745
savedInstanceState.putBoolean(STATE_WEBVIEW_CRASHED, webViewCrashed);
697746
savedInstanceState.putParcelable(STATE_URI, mUri);
698747
savedInstanceState.putInt(STATE_PAGE, mPage);
748+
savedInstanceState.putInt(STATE_NUM_PAGES, mNumPages);
699749
savedInstanceState.putFloat(STATE_ZOOM_RATIO, mZoomRatio);
700750
savedInstanceState.putInt(STATE_DOCUMENT_ORIENTATION_DEGREES, mDocumentOrientationDegrees);
701751
savedInstanceState.putString(STATE_ENCRYPTED_DOCUMENT_PASSWORD, mEncryptedDocumentPassword);
@@ -827,6 +877,9 @@ public boolean onOptionsItemSelected(MenuItem item) {
827877
} else if (itemId == R.id.debug_action_crash_webview) {
828878
binding.webview.loadUrl("chrome://crash");
829879
return true;
880+
} else if (itemId == R.id.action_settings) {
881+
new SettingsDialog().show(getSupportFragmentManager(), "settings");
882+
return true;
830883
}
831884

832885
return super.onOptionsItemSelected(item);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package app.grapheneos.pdfviewer
2+
3+
4+
import android.content.Context
5+
6+
object PreferenceHelper {
7+
8+
const val PREF_NAME = "app_prefs"
9+
const val KEY_RESUME_LAST_DOCUMENT = "resume_last_document"
10+
11+
fun isResumeLastDocumentEnabled(context: Context): Boolean {
12+
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
13+
return prefs.getBoolean(KEY_RESUME_LAST_DOCUMENT, true)
14+
}
15+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package app.grapheneos.pdfviewer.fragment
2+
3+
import android.app.Dialog
4+
import android.content.Context
5+
import android.os.Bundle
6+
import androidx.core.content.edit
7+
import androidx.fragment.app.DialogFragment
8+
import app.grapheneos.pdfviewer.PreferenceHelper
9+
import app.grapheneos.pdfviewer.R
10+
import app.grapheneos.pdfviewer.databinding.DialogSettingsBinding
11+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
12+
13+
class SettingsDialog : DialogFragment() {
14+
15+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
16+
val binding = DialogSettingsBinding.inflate(layoutInflater)
17+
18+
val prefs = requireContext().getSharedPreferences(PreferenceHelper.PREF_NAME, Context.MODE_PRIVATE)
19+
binding.switchResumeDocument.isChecked =
20+
prefs.getBoolean(PreferenceHelper.KEY_RESUME_LAST_DOCUMENT, true)
21+
binding.switchResumeDocument.setOnCheckedChangeListener { _, isChecked ->
22+
prefs.edit {
23+
putBoolean(PreferenceHelper.KEY_RESUME_LAST_DOCUMENT, isChecked)
24+
}
25+
}
26+
27+
return MaterialAlertDialogBuilder(requireContext())
28+
.setTitle(R.string.settings)
29+
.setView(binding.root)
30+
.create()
31+
}
32+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package app.grapheneos.pdfviewer.preferences
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.core.IOException
6+
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
7+
import androidx.datastore.preferences.core.Preferences
8+
import androidx.datastore.preferences.core.edit
9+
import androidx.datastore.preferences.core.emptyPreferences
10+
import androidx.datastore.preferences.core.intPreferencesKey
11+
import androidx.datastore.preferences.core.stringPreferencesKey
12+
import androidx.datastore.preferences.preferencesDataStore
13+
import kotlinx.coroutines.flow.Flow
14+
import kotlinx.coroutines.flow.catch
15+
import kotlinx.coroutines.flow.first
16+
import kotlinx.coroutines.flow.map
17+
import org.json.JSONObject
18+
19+
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
20+
name = "pdf_preferences",
21+
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }
22+
)
23+
24+
class PdfPreferencesRepository (private val context: Context) {
25+
26+
private object PreferencesKeys {
27+
val LAST_OPENED_URI = stringPreferencesKey("last_opened_uri")
28+
val LAST_OPENED_PAGE = intPreferencesKey("last_opened_page")
29+
val FILE_PAGE_POSITIONS = stringPreferencesKey("file_page_positions")
30+
}
31+
32+
companion object {
33+
private const val MAX_FILE_HISTORY = 50
34+
}
35+
36+
data class PdfState(
37+
val lastOpenedUri: String? = null,
38+
val lastOpenedPage: Int = 1,
39+
val filePagePositions: Map<String, Int> = emptyMap()
40+
)
41+
42+
val pdfStateFlow: Flow<PdfState> = context.dataStore.data
43+
.catch { exception ->
44+
if (exception is IOException) {
45+
emit(emptyPreferences())
46+
} else {
47+
throw exception
48+
}
49+
}
50+
.map { preferences ->
51+
PdfState(
52+
lastOpenedUri = preferences[PreferencesKeys.LAST_OPENED_URI],
53+
lastOpenedPage = preferences[PreferencesKeys.LAST_OPENED_PAGE] ?: 1,
54+
filePagePositions = parseFilePagePositions(
55+
preferences[PreferencesKeys.FILE_PAGE_POSITIONS]
56+
)
57+
)
58+
}
59+
60+
suspend fun saveLastOpened(uri: String?, page: Int) {
61+
context.dataStore.edit { preferences ->
62+
if (uri != null) {
63+
preferences[PreferencesKeys.LAST_OPENED_URI] = uri
64+
} else {
65+
preferences.remove(PreferencesKeys.LAST_OPENED_URI)
66+
}
67+
preferences[PreferencesKeys.LAST_OPENED_PAGE] = page
68+
}
69+
}
70+
71+
suspend fun clearLastOpened() {
72+
context.dataStore.edit { preferences ->
73+
preferences.remove(PreferencesKeys.LAST_OPENED_URI)
74+
preferences.remove(PreferencesKeys.LAST_OPENED_PAGE)
75+
}
76+
}
77+
78+
suspend fun updatePagePosition(fileHash: String, page: Int) {
79+
context.dataStore.edit { preferences ->
80+
val currentMap = parseFilePagePositions(
81+
preferences[PreferencesKeys.FILE_PAGE_POSITIONS]
82+
).toMutableMap()
83+
84+
currentMap[fileHash] = page
85+
86+
if (currentMap.size > MAX_FILE_HISTORY) {
87+
val toRemove = currentMap.keys.take(currentMap.size - MAX_FILE_HISTORY)
88+
toRemove.forEach { currentMap.remove(it) }
89+
}
90+
91+
preferences[PreferencesKeys.FILE_PAGE_POSITIONS] = serializeFilePagePositions(currentMap)
92+
}
93+
}
94+
95+
suspend fun getPageForFile(fileHash: String): Int? {
96+
return pdfStateFlow.first().filePagePositions[fileHash]
97+
}
98+
99+
private fun parseFilePagePositions(json: String?): Map<String, Int> {
100+
if (json.isNullOrEmpty()) return emptyMap()
101+
val jsonObject = JSONObject(json)
102+
return buildMap {
103+
jsonObject.keys().forEach { key ->
104+
put(key, jsonObject.getInt(key))
105+
}
106+
}
107+
}
108+
109+
private fun serializeFilePagePositions(map: Map<String, Int>): String {
110+
return JSONObject(map).toString()
111+
}
112+
}

0 commit comments

Comments
 (0)