Notice
Recent Posts
Recent Comments
Link
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Archives
Today
Total
관리 메뉴

안드로이드 개발일기

[Android] PDF 로드 및 관리 with Compose 본문

android

[Android] PDF 로드 및 관리 with Compose

lolvlol 2024. 3. 23. 15:27

1. 파일 선택기로 pdf 파일 가져오기

앱은 사용자에게 GET_CONTENT로 파일을 로드하고 pdf를 가져오도록 기능을 제공해야 합니다.

아래는 ManagedActivityResultLauncher<Intent, ActivityResult>를 사용하여 액션을 수행하고 결과 값으로 pdf를 가지고 반환하는 코드입니다.

val openDocumentLauncher: ManagedActivityResultLauncher<Intent, ActivityResult> =
    rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult()
    ) { result: ActivityResult? ->
        val uri = result?.data?.data
        Log.i(TAG, "pdf uri $uri")
    }
    

...
    
Text(
	text = "get pdf",
    modifier = Modifier.onClick {
    	onClickAddButton(openDocumentLauncher)
    }
}


...

private fun onClickAddButton(
        launcher: ManagedActivityResultLauncher<Intent, ActivityResult>
    ) {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "application/pdf"
        }
        launcher.launch(intent)
    }

 

위 코드는 Text를 클릭했을 때 사용자에게 pdf 파일을 선택하고 이를 리턴받는 구조입니다. 

ManagedActivityResultLauncher<Intent, ActivityResult>를 사용하는데, input은 Intent로 전달하여 사용자에게 pdf 파일을 선택하도록 type을 지정하여 launcher를 실행합니다.

 

결과는 ActivityResult로 반환 받습니다. 반환 값은 openDocumentLauncher의 스코프로 전달받게 됩니다. 해당 스코프에서 uri를 확인할 수 있습니다.

 

스코프의 과정에서 Android11부터는 파일 임시 접근 권한을 자동으로 부여받습니다. 앱을 종료하거나 기기를 재시작하면 접근 권한을 잃을 수 있기 때문에 아래 단계에서 권한을 영구적으로 만들어야 합니다.

 

2. 선택한 파일에 Permission 부여

val openDocumentLauncher: ManagedActivityResultLauncher<Intent, ActivityResult> =
    rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult()
    ) { result: ActivityResult? ->
        val uri = result?.data?.data
        
        ////// 추가 코드 //////
        if (uri != null) {
            val takeFlags = result.data?.flags?.and(
                Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            ) ?: 0
            context.contentResolver.takePersistableUriPermission(
                uri, takeFlags
            )
            // viewModel에 이벤트 전송
            viewModel.sendEvent(HomeContract.Event.AddPdfData(uri = uri))
        }
        /////////////////////
    }

 

가져온 pdf 파일을 읽으려면 takePersistableUriPermission 을 줘야 합니다.

그렇지 않으면, pdf 파일을 openPage(i)로 읽을 수 없고, 아래 런타임 에러가 발생합니다.

 

java.lang.SecurityException: Permission Denial: reading com.android.providers.media.MediaDocumentsProvider uri content://com.android.providers.media.documents/document/document:1000000087 from pid=9473, uid=10471 requires that you obtain access using ACTION_OPEN_DOCUMENT or related APIs

 

위의 오류는 파일의 접근 권한이 적절히 설정되지 않았을 때 발생합니다.

Intent.ACTION_OPEN_DOCUMENT로 파일을 선택한 경우 임시적 접근 권한이 부여되는데, 영구적 권한으로 바꾸기 위해서는 takePersistableUriPermission를 사용합니다.

 

3. Uri를 Pdf 파일로 변환

data class PdfPageData(
    val pageIndex: Int,
    val bitmap: Bitmap
)

...

val bitmapList = mutableListOf<PdfPageData>()
withContext(Dispatchers.IO) {
    try {
        contentResolver.openFileDescriptor(uri, "r").use { parcelFileDescriptor ->
            val pdfRenderer = parcelFileDescriptor?.let { PdfRenderer(it) }
            for (i in startIndex until totalPage) {
                pdfRenderer?.openPage(i)?.let { page ->
                    val bitmap =
                        Bitmap.createBitmap(
                            page.width,
                            page.height,
                            Bitmap.Config.ARGB_8888
                        )
                    page.render(
                        bitmap,
                        null,
                        null,
                        PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
                    )
                    bitmapList.add(
                        PdfPageData(
                            pageIndex = i,
                            bitmap = bitmap
                        )
                    )
                    page.close()
                }
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}
return bitmapList

 

파일을 읽기 위해서는 contentResolver를 사용합니다.

ParcelFileDescriptor은 파일을 open, close에 사용합니다. Pdf 파일의 각 페이지를 읽어오기 위해서는 PdfRenderer 객체를 사용해야 합니다.pdfRenderer.openPage(i)로 i번째 페이지를 읽어올 수 있습니다.

 

openPage(i)로 읽어온 PdfRenderer.Page를 Bitmap으로 변환하여 리스트에 저장합니다. 이렇게 pdf 파일의 페이지들을 비트맵 리스트로 만들어서 화면에 로드할 것입니다.

 

번외. 페이지 외 Pdf 파일 정보 읽어오기

1. 파일 이름 가져오기

private fun getFileName(uri: Uri, contentResolver: ContentResolver): String? {
    var fileName: String? = null
    val cursor = contentResolver.query(uri, null, null, null, null)
    cursor?.use { cursor ->
        if (cursor.moveToFirst()) {
            val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
            fileName = if (nameIndex >= 0) cursor.getString(nameIndex) else null
        }
    }
    return fileName
}

 

2. 파일 총 페이지 수 가져오기

private fun getTotalPage(uri: Uri, contentResolver: ContentResolver): Int? {
    var pageCount: Int? = null
    runCatching {
        contentResolver.openFileDescriptor(uri, "r").use { pfd ->
            val pdfRenderer = pfd?.let { PdfRenderer(it) }
            pageCount = pdfRenderer?.pageCount ?: -1
        }
    }
    return pageCount
}

 

체크 포인트 1. openPage는 병렬로 처리할 수 없다

 

왜냐하면, PdfRenderer는 동시에 열 수 있는 페이지 수가 제한되어있고, 페이지 사용이 끝난 후에는 페이지를 닫아줘야 하는데 병렬로는 해당 관리를 할 수 없기 때문입니다.

 

(pdf 파일이 큰 경우 빠르게 페이지들을 읽어오기 위해 코루틴으로 병렬 처리를 시도해보았는데, 런타임 에러가 발생하였습니다. 알고보니 위의 이유 때문이었습니다.)

 

 

4.  HorizontalPager + AsyncImage로 화면에 로드

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PdfViewer(
    modifier: Modifier,
    index: Int,
    pageList: List<PdfPageData>,
) {
    val pagerState = rememberPagerState { pageList.size }

    HorizontalPager(
        state = pagerState,
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically
    ) { pagerIndex ->
        val data = pageList[pagerIndex]

        AsyncImage(
            modifier = Modifier.fillMaxSize(),
            contentDescription = "PDF Page ${data.pageIndex}",
            contentScale = ContentScale.FillBounds,
            model = ImageRequest.Builder(LocalContext.current)
                .data(data.bitmap)
                .dispatcher(Dispatchers.IO)
                .placeholder(R.drawable.img_placeholder)
                .error(R.drawable.error_image)
                .size(Size.ORIGINAL)
                .build()
        )
    }
}
Comments