Skip to content

Commit 653528c

Browse files
author
Dillon Simpson
committed
Add a FileDescriptor-based API for PdfLoader.
Not all PDFs come from content:// URIs. A FileDescriptor based API unblocks apps that download PDFs from web services or load PDFs from Files in their private storage from using the libary. Drive is such an app. This API still accepts a URI: 1) As a unique identifier. Bound services produce unique IBinders for unique Intents. It's critical we have some way to provide a unique Intent per document, and setting the data field is a good way to accomplish this. 2) PdfDocument exposes Uri in its API. This is potentially worth reconsidering, but it's not terrible to keep in place given #1. Relnote: Adds FileDescriptor API to PdfLoader Test: ./gradlew :pdf:pdf-document-service:cAT Change-Id: I60b8dfa37b4c3800392f2d14992375b9d2581f05
1 parent b98f4c4 commit 653528c

9 files changed

Lines changed: 154 additions & 14 deletions

File tree

pdf/pdf-document-service/api/current.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package androidx.pdf {
33

44
public final class SandboxedPdfLoader implements androidx.pdf.PdfLoader {
55
ctor public SandboxedPdfLoader(android.content.Context context, optional kotlin.coroutines.CoroutineContext coroutineContext);
6+
method public suspend Object? openDocument(android.net.Uri uri, android.os.ParcelFileDescriptor fileDescriptor, String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>);
67
method public suspend Object? openDocument(android.net.Uri uri, String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>);
78
}
89

pdf/pdf-document-service/api/restricted_current.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package androidx.pdf {
33

44
public final class SandboxedPdfLoader implements androidx.pdf.PdfLoader {
55
ctor public SandboxedPdfLoader(android.content.Context context, optional kotlin.coroutines.CoroutineContext coroutineContext);
6+
method public suspend Object? openDocument(android.net.Uri uri, android.os.ParcelFileDescriptor fileDescriptor, String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>);
67
method public suspend Object? openDocument(android.net.Uri uri, String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>);
78
}
89

pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/SandboxedPdfLoaderTest.kt

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package androidx.pdf
1818

1919
import android.content.Context
20+
import android.net.Uri
2021
import android.os.Build
2122
import androidx.pdf.service.connect.FakePdfServiceConnection
2223
import androidx.pdf.utils.TestUtils
@@ -35,7 +36,7 @@ import org.junit.runner.RunWith
3536
@RunWith(AndroidJUnit4::class)
3637
class SandboxedPdfLoaderTest {
3738
@Test
38-
fun openDocument_notConnected_connectsAndLoadsDocument() = runTest {
39+
fun openDocumentUri_notConnected_connectsAndLoadsDocument() = runTest {
3940
var isServiceConnected = false
4041
val context = ApplicationProvider.getApplicationContext<Context>()
4142
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
@@ -53,8 +54,27 @@ class SandboxedPdfLoaderTest {
5354
document.close()
5455
}
5556

57+
@Test
58+
fun openDocumentFd_notConnected_connectsAndLoadsDocument() = runTest {
59+
var isServiceConnected = false
60+
val context = ApplicationProvider.getApplicationContext<Context>()
61+
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
62+
loader.testingConnection =
63+
FakePdfServiceConnection(context, isConnected = false) { isServiceConnected = true }
64+
val pfd = TestUtils.openFileDescriptor(context, PDF_DOCUMENT)
65+
66+
val document = loader.openDocument(FAKE_URI_1, pfd)
67+
68+
val expectedPageCount = 3
69+
assertThat(isServiceConnected).isTrue()
70+
assertThat(document.uri == FAKE_URI_1).isTrue()
71+
assertThat(document.pageCount == expectedPageCount).isTrue()
72+
assertThat(!document.isLinearized).isTrue()
73+
document.close()
74+
}
75+
5676
@Test(expected = IllegalStateException::class)
57-
fun openDocument_connectedAndNullBinder_throwsIllegalStateException() {
77+
fun openDocumentUri_connectedAndNullBinder_throwsIllegalStateException() {
5878
val context = ApplicationProvider.getApplicationContext<Context>()
5979
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
6080
loader.testingConnection = FakePdfServiceConnection(context, isConnected = true)
@@ -63,18 +83,47 @@ class SandboxedPdfLoaderTest {
6383
runTest { loader.openDocument(uri) }
6484
}
6585

86+
@Test(expected = IllegalStateException::class)
87+
fun openDocumentFd_connectedAndNullBinder_throwsIllegalStateException() {
88+
val context = ApplicationProvider.getApplicationContext<Context>()
89+
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
90+
loader.testingConnection = FakePdfServiceConnection(context, isConnected = true)
91+
92+
val pfd = TestUtils.openFileDescriptor(context, PDF_DOCUMENT)
93+
runTest { loader.openDocument(FAKE_URI_1, pfd) }
94+
}
95+
6696
@Test(expected = PdfPasswordException::class)
67-
fun openDocument_passwordProtected_throwsPdfPasswordException() {
97+
fun openDocumentUri_passwordProtected_throwsPdfPasswordException() {
6898
val context = ApplicationProvider.getApplicationContext<Context>()
6999
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
70100

71101
val uri = TestUtils.openFile(context, PASSWORD_PROTECTED_DOCUMENT)
72-
73102
runTest { loader.openDocument(uri) }
74103
}
75104

105+
@Test(expected = PdfPasswordException::class)
106+
fun openDocumentFd_passwordProtected_throwsPdfPasswordException() {
107+
val context = ApplicationProvider.getApplicationContext<Context>()
108+
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
109+
110+
val pfd = TestUtils.openFileDescriptor(context, PASSWORD_PROTECTED_DOCUMENT)
111+
112+
runTest { loader.openDocument(FAKE_URI_1, pfd) }
113+
}
114+
115+
@Test(expected = IllegalStateException::class)
116+
fun openDocumentUri_corruptedDocument_throwsIllegalStateException() {
117+
val context = ApplicationProvider.getApplicationContext<Context>()
118+
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
119+
120+
val pfd = TestUtils.openFileDescriptor(context, CORRUPTED_DOCUMENT)
121+
122+
runTest { loader.openDocument(FAKE_URI_1, pfd) }
123+
}
124+
76125
@Test(expected = IllegalStateException::class)
77-
fun openDocument_corruptedDocument_throwsIllegalStateException() {
126+
fun openDocumentFd_corruptedDocument_throwsIllegalStateException() {
78127
val context = ApplicationProvider.getApplicationContext<Context>()
79128
val loader = SandboxedPdfLoader(context, Dispatchers.Main)
80129

@@ -88,7 +137,7 @@ class SandboxedPdfLoaderTest {
88137
* document's internal state. See b/380140417
89138
*/
90139
@Test
91-
fun openTwoDocuments_sharedLoader() = runTest {
140+
fun openTwoDocumentsUri_sharedLoader() = runTest {
92141
val context = ApplicationProvider.getApplicationContext<Context>()
93142
val uri1 = TestUtils.openFile(context, "sample.pdf")
94143
val uri2 = TestUtils.openFile(context, "alt_text.pdf")
@@ -111,9 +160,38 @@ class SandboxedPdfLoaderTest {
111160
assertThat(document1.getPageInfo(2).width).isEqualTo(doc1Page3Info.width)
112161
}
113162

163+
@Test
164+
fun openTwoDocumentsFd_sharedLoader() = runTest {
165+
val context = ApplicationProvider.getApplicationContext<Context>()
166+
val pfd1 = TestUtils.openFileDescriptor(context, "sample.pdf")
167+
val pfd2 = TestUtils.openFileDescriptor(context, "alt_text.pdf")
168+
val sharedLoader = SandboxedPdfLoader(context, Dispatchers.Main)
169+
170+
// Grab some data from document1
171+
val document1 = sharedLoader.openDocument(FAKE_URI_1, pfd1)
172+
assertThat(document1.pageCount).isEqualTo(3)
173+
val doc1Page3Info = document1.getPageInfo(2)
174+
val doc1Page1Text = document1.getPageContent(0)?.textContents?.get(0)?.text
175+
176+
// Load document2, make a basic assertion to verify it is indeed a different PDF document
177+
val document2 = sharedLoader.openDocument(FAKE_URI_2, pfd2)
178+
assertThat(document2.pageCount).isEqualTo(1)
179+
180+
// Make sure we receive the same data from document1 as before, i.e. that loading document2
181+
// did not in any way corrupt document1
182+
assertThat(document1.getPageContent(0)?.textContents?.get(0)?.text).isEqualTo(doc1Page1Text)
183+
assertThat(document1.getPageInfo(2).height).isEqualTo(doc1Page3Info.height)
184+
assertThat(document1.getPageInfo(2).width).isEqualTo(doc1Page3Info.width)
185+
}
186+
114187
companion object {
115188
private const val PDF_DOCUMENT = "sample.pdf"
116189
private const val PASSWORD_PROTECTED_DOCUMENT = "sample-protected.pdf"
117190
private const val CORRUPTED_DOCUMENT = "corrupted.pdf"
191+
192+
// We deliberately use fake URIs in the file descriptor versions of these tests to validate
193+
// the file descriptor API's behavior of using the URI only as a unique identifier.
194+
private val FAKE_URI_1 = Uri.parse("content://who.cares/not_a.pdf")
195+
private val FAKE_URI_2 = Uri.parse("http://this_is.not/even_a_scheme_we_support.html")
118196
}
119197
}

pdf/pdf-document-service/src/androidTest/kotlin/androidx/pdf/utils/TestUtils.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ package androidx.pdf.utils
1818

1919
import android.content.Context
2020
import android.net.Uri
21+
import android.os.ParcelFileDescriptor
2122
import java.io.File
2223
import java.io.FileOutputStream
24+
import java.io.IOException
2325
import java.io.InputStream
2426

2527
object TestUtils {
@@ -31,6 +33,12 @@ object TestUtils {
3133
return saveStream(context, inputStream)
3234
}
3335

36+
fun openFileDescriptor(context: Context, filename: String): ParcelFileDescriptor {
37+
val uri = openFile(context, filename)
38+
return context.contentResolver.openFileDescriptor(uri, "rw")
39+
?: throw IOException("Failed to open PDF file")
40+
}
41+
3442
private fun saveStream(context: Context, inputStream: InputStream): Uri {
3543
val tempFile = File.createTempFile(TEMP_FILE_NAME, TEMP_FILE_TYPE, context.cacheDir)
3644
FileOutputStream(tempFile).use { outputStream -> inputStream.copyTo(outputStream) }

pdf/pdf-document-service/src/main/kotlin/androidx/pdf/SandboxedPdfLoader.kt

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,38 @@ public class SandboxedPdfLoader(
6565
internal var testingConnection: PdfServiceConnection? = null
6666

6767
override suspend fun openDocument(uri: Uri, password: String?): PdfDocument {
68+
val connection = connect(uri)
69+
70+
return withContext(resolveCoroutineContext(coroutineContext)) {
71+
val pfd = openFileDescriptor(uri)
72+
openDocumentInternal(uri, pfd, password, connection)
73+
}
74+
}
75+
76+
override suspend fun openDocument(
77+
uri: Uri,
78+
fileDescriptor: ParcelFileDescriptor,
79+
password: String?,
80+
): PdfDocument {
81+
val connection = connect(uri)
82+
83+
return withContext(resolveCoroutineContext(coroutineContext)) {
84+
openDocumentInternal(uri, fileDescriptor, password, connection)
85+
}
86+
}
87+
88+
private suspend fun connect(uri: Uri): PdfServiceConnection {
6889
val connection: PdfServiceConnection =
6990
testingConnection ?: PdfServiceConnectionImpl(context)
7091
if (!connection.isConnected) {
7192
connection.connect(uri)
7293
}
73-
74-
return withContext(resolveCoroutineContext(coroutineContext)) {
75-
openDocumentUri(uri, password, connection)
76-
}
94+
return connection
7795
}
7896

79-
private fun openDocumentUri(
97+
private fun openDocumentInternal(
8098
uri: Uri,
99+
pfd: ParcelFileDescriptor,
81100
password: String?,
82101
connection: PdfServiceConnection,
83102
): PdfDocument {
@@ -86,8 +105,7 @@ public class SandboxedPdfLoader(
86105
?: throw IllegalStateException(
87106
"Binder interface not available for loading the document!"
88107
)
89-
val pfd = openFileDescriptor(uri)
90-
val status = PdfLoadingStatus.values()[binder.openPdfDocument(pfd, password)]
108+
val status = PdfLoadingStatus.entries[binder.openPdfDocument(pfd, password)]
91109

92110
if (status != PdfLoadingStatus.SUCCESS) {
93111
handlePdfLoadingError(pfd, status)

pdf/pdf-viewer-fragment/src/androidTest/kotlin/androidx/pdf/viewer/document/FakePdfLoader.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@
1717
package androidx.pdf.viewer.document
1818

1919
import android.net.Uri
20+
import android.os.ParcelFileDescriptor
2021
import androidx.pdf.PdfDocument
2122
import androidx.pdf.PdfLoader
2223

2324
class FakePdfLoader(private val fakePdfDocument: PdfDocument) : PdfLoader {
2425

2526
override suspend fun openDocument(uri: Uri, password: String?): PdfDocument = fakePdfDocument
27+
28+
override suspend fun openDocument(
29+
uri: Uri,
30+
fileDescriptor: ParcelFileDescriptor,
31+
password: String?,
32+
): PdfDocument = fakePdfDocument
2633
}

pdf/pdf-viewer/api/current.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ package androidx.pdf {
5454
}
5555

5656
public interface PdfLoader {
57+
method @kotlin.jvm.Throws(exceptionClasses=IOException::class) public suspend Object? openDocument(android.net.Uri uri, android.os.ParcelFileDescriptor fileDescriptor, optional String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>) throws java.io.IOException;
5758
method @kotlin.jvm.Throws(exceptionClasses=IOException::class) public suspend Object? openDocument(android.net.Uri uri, optional String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>) throws java.io.IOException;
5859
}
5960

pdf/pdf-viewer/api/restricted_current.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ package androidx.pdf {
5454
}
5555

5656
public interface PdfLoader {
57+
method @kotlin.jvm.Throws(exceptionClasses=IOException::class) public suspend Object? openDocument(android.net.Uri uri, android.os.ParcelFileDescriptor fileDescriptor, optional String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>) throws java.io.IOException;
5758
method @kotlin.jvm.Throws(exceptionClasses=IOException::class) public suspend Object? openDocument(android.net.Uri uri, optional String? password, kotlin.coroutines.Continuation<? super androidx.pdf.PdfDocument>) throws java.io.IOException;
5859
}
5960

pdf/pdf-viewer/src/main/kotlin/androidx/pdf/PdfLoader.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package androidx.pdf
1818

1919
import android.net.Uri
20+
import android.os.ParcelFileDescriptor
2021
import java.io.IOException
2122

2223
/**
@@ -27,7 +28,7 @@ import java.io.IOException
2728
public interface PdfLoader {
2829

2930
/**
30-
* Asynchronously opens a PDF document from the specified [Uri].
31+
* Asynchronously opens a [PdfDocument] from the specified [Uri].
3132
*
3233
* @param uri The URI of the PDF document to open.
3334
* @param password (Optional) The password to unlock the document if it is encrypted.
@@ -37,4 +38,28 @@ public interface PdfLoader {
3738
*/
3839
@Throws(IOException::class)
3940
public suspend fun openDocument(uri: Uri, password: String? = null): PdfDocument
41+
42+
/**
43+
* Asynchronously opens a [PdfDocument] from the specified [fileDescriptor]. The file descriptor
44+
* will become owned by the [PdfDocument], and it should be closed using the document, unless an
45+
* exception is thrown.
46+
*
47+
* @param uri This is used only as a unique identifier for the [PdfDocument]. The source of
48+
* truth for accessing file contents is in this case the [fileDescriptor]. If you don't have
49+
* access to the [Uri] which produced the file descriptor, or the file descriptor was not
50+
* produced by opening a URI, it's acceptable to provide a "fake" one here, so long as the
51+
* value uniquely identifies the document.
52+
* @param fileDescriptor a [ParcelFileDescriptor] pointing at the PDF content to be opened. Must
53+
* be seekable.
54+
* @param password (Optional) The password to unlock the document if it is encrypted.
55+
* @return The opened [PdfDocument].
56+
* @throws PdfPasswordException If the provided password is incorrect.
57+
* @throws IOException If an error occurs while opening the document.
58+
*/
59+
@Throws(IOException::class)
60+
public suspend fun openDocument(
61+
uri: Uri,
62+
fileDescriptor: ParcelFileDescriptor,
63+
password: String? = null,
64+
): PdfDocument
4065
}

0 commit comments

Comments
 (0)