添加合并pdf,遇到有密码的文件。正常处理。

This commit is contained in:
ocean 2025-10-16 18:08:58 +08:00
parent 0bc4a4fab2
commit 8504e7cbea
9 changed files with 227 additions and 100 deletions

View File

@ -32,7 +32,8 @@ data class PdfDocumentEntity(
val metadataCreationDate: Long? = null, // PDF创建时间
val metadataModificationDate: Long? = null, // PDF修改时间
val password: String? = null,// PDF密码加密存储
val isPassword: Boolean = false,//是否存在密码
var password: String? = null,// PDF密码加密存储
var isSelected: Boolean = false
) : Parcelable

View File

@ -1,5 +1,6 @@
package com.all.pdfreader.pro.app.ui.act
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Build
@ -7,21 +8,29 @@ import android.os.Bundle
import android.os.Parcelable
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContract
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.ActivityPdfMergeBinding
import com.all.pdfreader.pro.app.model.PdfPickerSource
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.ui.adapter.PdfAdapter
import com.all.pdfreader.pro.app.ui.dialog.PdfPasswordProtectionDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.PromptDialogFragment
import com.all.pdfreader.pro.app.util.AppUtils.setClickWithAnimation
import com.all.pdfreader.pro.app.util.AppUtils.setOnSingleClickListener
import com.all.pdfreader.pro.app.util.FileUtils.isPdfEncrypted
import com.gyf.immersionbar.ImmersionBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
class MergePdfActivity : BaseActivity() {
override val TAG: String = "MergePdfActivity"
private lateinit var binding: ActivityPdfMergeBinding
private lateinit var selectedList: ArrayList<PdfDocumentEntity>
private var selectedList: ArrayList<PdfDocumentEntity> = arrayListOf()
private lateinit var adapter: PdfAdapter
override fun onCreate(savedInstanceState: Bundle?) {
@ -31,11 +40,60 @@ class MergePdfActivity : BaseActivity() {
setupBackPressedCallback()
ImmersionBar.with(this).statusBarView(binding.view).statusBarDarkFont(true)
.navigationBarColor(R.color.bg_color).init()
selectedList = requireParcelableArrayList(EXTRA_PDF_LIST)
updateContinueNowBtnState(selectedList.size >= 2)
val list: ArrayList<PdfDocumentEntity> = requireParcelableArrayList(EXTRA_PDF_LIST)
updateContinueNowBtnState(list.size >= 2)
lifecycleScope.launch {
handlePasswordProtectedPdfs(list) {
list.forEach {
logDebug("onCreate password->${it.password}")
logDebug("onCreate isPassword->${it.isPassword}")
}
selectedList.clear()
selectedList.addAll(list)
initView()
setupClick()
}
}
}
/**
* 遍历集合弹出密码对话框并更新 password 字段
*/
private suspend fun handlePasswordProtectedPdfs(
pdfList: ArrayList<PdfDocumentEntity>,
onAllPasswordsReady: (ArrayList<PdfDocumentEntity>) -> Unit
) {
for (pdf in pdfList) {
if (pdf.isPassword && pdf.password.isNullOrEmpty()) {
val pwd = showPasswordDialogSuspend(File(pdf.filePath)) ?: run {
finish()
return
}
pdf.password = pwd
}
}
onAllPasswordsReady(pdfList)
}
/**
* DialogFragment 封装成 suspend 函数
*/
private suspend fun showPasswordDialogSuspend(file: File): String? =
suspendCancellableCoroutine { cont ->
PdfPasswordProtectionDialogFragment(
file,
onOkClick = { password ->
if (cont.isActive) {
cont.resumeWith(Result.success(password))
}
},
onCancelClick = {
if (cont.isActive) {
cont.resumeWith(Result.success(null))
}
}, isPrompt = true
).show(supportFragmentManager, TAG)
}
private fun initView() {
adapter = PdfAdapter(
@ -61,9 +119,8 @@ class MergePdfActivity : BaseActivity() {
openPicker()
}
binding.continueNowBtn.setOnSingleClickListener {
val list = selectedList.map { it.filePath }
val intent = PdfResultActivity.createIntentInputFile(
this, ArrayList(list), PdfPickerSource.MERGE
val intent = PdfResultActivity.createIntentMergePdfActivityToResult(
this, selectedList, PdfPickerSource.MERGE
)
startActivity(intent)
finish()
@ -73,7 +130,7 @@ class MergePdfActivity : BaseActivity() {
private val pickPdfLauncher =
registerForActivityResult(PickPdfContract(Companion.TAG)) { list ->
if (list.isNotEmpty()) {
handleSelectedPdfs(list)
handleSelectedPdfs(list as ArrayList<PdfDocumentEntity>)
}
}
@ -81,12 +138,21 @@ class MergePdfActivity : BaseActivity() {
pickPdfLauncher.launch(PdfPickerSource.MERGE to selectedList)
}
private fun handleSelectedPdfs(list: List<PdfDocumentEntity>) {
@SuppressLint("NotifyDataSetChanged")
private fun handleSelectedPdfs(list: ArrayList<PdfDocumentEntity>) {
lifecycleScope.launch {
handlePasswordProtectedPdfs(list) {
list.forEach {
logDebug("handleSelectedPdfs password->${it.password}")
logDebug("handleSelectedPdfs isPassword->${it.isPassword}")
}
selectedList.clear()
selectedList.addAll(list)
adapter.notifyDataSetChanged()
updateContinueNowBtnState(selectedList.size >= 2)
}
}
}
private fun updateContinueNowBtnState(b: Boolean) {
binding.continueNowBtn.setBackgroundResource(

View File

@ -155,9 +155,16 @@ class PdfPickerActivity : BaseActivity() {
private fun markSelectedItems(
sortedList: List<PdfDocumentEntity>, historyList: List<PdfDocumentEntity>
) {
val historySet = historyList.map { it.filePath }.toSet()
val historyMap = historyList.associateBy { it.filePath }
sortedList.forEach { item ->
item.isSelected = item.filePath in historySet
val historyItem = historyMap[item.filePath]
if (historyItem != null) {
item.isSelected = true
// 同步历史密码
item.password = historyItem.password
} else {
item.isSelected = false
}
}
}

View File

@ -16,6 +16,7 @@ import com.all.pdfreader.pro.app.databinding.ActivityPdfSplitResultBinding
import com.all.pdfreader.pro.app.model.PdfPickerSource
import com.all.pdfreader.pro.app.model.PdfSelectedPagesItem
import com.all.pdfreader.pro.app.model.PdfSplitResultItem
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.ui.adapter.PdfResultAdapter
import com.all.pdfreader.pro.app.ui.dialog.PromptDialogFragment
import com.all.pdfreader.pro.app.util.AppUtils
@ -37,12 +38,13 @@ class PdfResultActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_LIST = "extra_selected_list"
private const val EXTRA_FILE_LIST = "extra_file_list"
private const val EXTRA_MERGE_LIST = "extra_merge_list"//合并传输使用的key
private const val EXTRA_SOURCE = "extra_source"
private const val EXTRA_LOCK_UNLOCK_PASSWORD = "extra_lock_unlock_password"
private const val EXTRA_FILE_PATH = "extra_file_path"
private const val EXTRA_SPLIT_PASSWORD = "extra_split_password"
fun createIntentPdfSelectedPagesItem(
fun createIntentSplitPdfActivityToResult(
context: Context,
list: ArrayList<PdfSelectedPagesItem>,
source: PdfPickerSource,
@ -64,6 +66,15 @@ class PdfResultActivity : BaseActivity() {
}
}
fun createIntentMergePdfActivityToResult(
context: Context, list: ArrayList<PdfDocumentEntity>, source: PdfPickerSource
): Intent {
return Intent(context, PdfResultActivity::class.java).apply {
putParcelableArrayListExtra(EXTRA_MERGE_LIST, list)
putExtra(EXTRA_SOURCE, source)
}
}
fun createIntentLock(
context: Context, filepath: String, password: String, source: PdfPickerSource
): Intent {
@ -78,15 +89,11 @@ class PdfResultActivity : BaseActivity() {
private lateinit var binding: ActivityPdfSplitResultBinding
private lateinit var adapter: PdfResultAdapter
private var resultList: MutableList<PdfSplitResultItem> = mutableListOf()
private lateinit var selectedList: ArrayList<PdfSelectedPagesItem>
private lateinit var inputFile: ArrayList<String>
private lateinit var source: PdfPickerSource
private var isProcessing = false
private var exitDialog: PromptDialogFragment? = null
private val pdfRepository = getRepository()
private lateinit var pdfScanner: PdfScanner
private lateinit var filepath: String
private lateinit var lockAndUnlockPassword: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -95,35 +102,11 @@ class PdfResultActivity : BaseActivity() {
setupBackPressedCallback()
ImmersionBar.with(this).statusBarView(binding.view).statusBarDarkFont(true)
.navigationBarColor(R.color.bg_color).init()
selectedList = requireParcelableArrayList(EXTRA_SELECTED_LIST)
inputFile = requireStringArrayList(EXTRA_FILE_LIST)
filepath = intent.getStringExtra(EXTRA_FILE_PATH) ?: ""
lockAndUnlockPassword = intent.getStringExtra(EXTRA_LOCK_UNLOCK_PASSWORD) ?: ""
source = getSerializableOrDefault(EXTRA_SOURCE, PdfPickerSource.NONE)
if (source == PdfPickerSource.NONE) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return
} else {
if (source == PdfPickerSource.SPLIT) {
if (selectedList.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return
}
} else if (source == PdfPickerSource.MERGE || source == PdfPickerSource.TO_IMAGES) {
if (inputFile.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return
}
} else if (source == PdfPickerSource.LOCK || source == PdfPickerSource.UNLOCK) {
if (filepath.isEmpty() || lockAndUnlockPassword.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return
}
}
}
pdfScanner = PdfScanner(this, pdfRepository)
initView()
@ -148,20 +131,26 @@ class PdfResultActivity : BaseActivity() {
binding.progressBar.isIndeterminate = false
binding.progressBar.progress = 0
binding.progressBar.max = 100
val selectedList: ArrayList<PdfSelectedPagesItem> =
requireParcelableArrayList(EXTRA_SELECTED_LIST)
if (selectedList.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return@launch
}
val totalPages = selectedList.sumOf { it.pages.count { it.isSelected } }
var processedPages = 0
for (item in selectedList) {
val selectedPages = item.pages.filter { it.isSelected }
if (selectedPages.isEmpty()) continue
val inputFile = File(item.filePath)
val splitInputFile = File(item.filePath)
val outputDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
"PDFReaderPro/split"
).apply { if (!exists()) mkdirs() }
PdfUtils.exportSelectedPages(
inputFile = inputFile,
inputFile = splitInputFile,
selectedPages = selectedPages,
outputDir = outputDir,
outputFileName = "${item.fileName}.pdf",
@ -172,7 +161,8 @@ class PdfResultActivity : BaseActivity() {
binding.progressTv.text = "$percent"
binding.progressBar.progress = percent
}
}, splitPassword
},
splitPassword
)?.let { resultFile ->
val thumbnails = generateFastThumbnail(this@PdfResultActivity, resultFile)
val result = PdfSplitResultItem(resultFile.absolutePath, thumbnails, false)
@ -185,8 +175,16 @@ class PdfResultActivity : BaseActivity() {
binding.progressBar.isIndeterminate = false
binding.progressBar.progress = 0
binding.progressBar.max = 100
if (inputFile.isNotEmpty()) {
val inputFiles: List<File> = inputFile.map { path -> File(path) }
val mergeInputFile: ArrayList<PdfDocumentEntity> =
requireParcelableArrayList(EXTRA_MERGE_LIST)
if (mergeInputFile.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return@launch
}
val inputWithPasswords = mergeInputFile.map { pdf ->
File(pdf.filePath) to pdf.password
}
val outputDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
"PDFReaderPro/merge"
@ -194,8 +192,8 @@ class PdfResultActivity : BaseActivity() {
val outputFileName =
getString(R.string.merge) + "_" + System.currentTimeMillis()
.toUnderscoreDateTime() + ".pdf"
PdfUtils.mergePdfFilesSafe(
inputFiles = inputFiles,
PdfUtils.mergePdfFilesWithPassword(
inputFiles = inputWithPasswords,
outputDir = outputDir,
outputFileName = outputFileName,
onProgress = { current, total ->
@ -211,11 +209,17 @@ class PdfResultActivity : BaseActivity() {
resultList.add(result)
}
}
}
} else if (source == PdfPickerSource.LOCK) {
val filepath = intent.getStringExtra(EXTRA_FILE_PATH) ?: ""
if (filepath.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return@launch
}
val lockPassword = intent.getStringExtra(EXTRA_LOCK_UNLOCK_PASSWORD) ?: ""
binding.congratulationsDesc.text = getString(R.string.set_password_successfully)
PdfSecurityUtils.setPasswordToPdfWithProgress(
filepath, lockAndUnlockPassword, lockAndUnlockPassword
filepath, lockPassword, lockPassword
) { progress ->
binding.progressTv.text = "$progress"
}.let { it ->
@ -226,25 +230,37 @@ class PdfResultActivity : BaseActivity() {
resultList.add(result)
}
} else if (source == PdfPickerSource.UNLOCK) {
val filepath = intent.getStringExtra(EXTRA_FILE_PATH) ?: ""
if (filepath.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return@launch
}
val unlockPassword = intent.getStringExtra(EXTRA_LOCK_UNLOCK_PASSWORD) ?: ""
binding.congratulationsDesc.text = getString(R.string.remove_password_successfully)
PdfSecurityUtils.removePasswordFromPdfWithProgress(filepath, lockAndUnlockPassword) { progress ->
PdfSecurityUtils.removePasswordFromPdfWithProgress(
filepath, unlockPassword
) { progress ->
binding.progressTv.text = "$progress"
}?.let { it ->
val result = PdfSplitResultItem(
filePath = filepath,
thumbnailPath = it,
isPassword = false
filePath = filepath, thumbnailPath = it, isPassword = false
)
resultList.add(result)
}
} else if (source == PdfPickerSource.TO_IMAGES) {
val inputFiles: List<File> = inputFile.map { path -> File(path) }
val files = requireStringArrayList(EXTRA_FILE_LIST)
if (files.isEmpty()) {
showToast(getString(R.string.pdf_loading_failed))
finish()
return@launch
}
val inputFiles: List<File> = files.map { path -> File(path) }
val outputDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
"PDFReaderPro/img2Pdf"
).apply { if (!exists()) mkdirs() }
val outputFileName =
getString(R.string.app_name) + "_" + System.currentTimeMillis()
val outputFileName = getString(R.string.app_name) + "_" + System.currentTimeMillis()
.toUnderscoreDateTime() + ".pdf"
PdfUtils.imgToPdfFilesSafe(
inputFiles = inputFiles,

View File

@ -141,7 +141,7 @@ class SplitPdfActivity : BaseActivity() {
binding.splitBtn.setOnSingleClickListener {
logDebug("${selectedList.size}")
//因为图片做的路径缓存方式所以这里直接传入整个集合到result页处理
val intent = PdfResultActivity.createIntentPdfSelectedPagesItem(
val intent = PdfResultActivity.createIntentSplitPdfActivityToResult(
this,
ArrayList(selectedList),
PdfPickerSource.SPLIT,

View File

@ -16,7 +16,8 @@ import java.io.File
class PdfPasswordProtectionDialogFragment(
private val file: File,
private val onOkClick: (String) -> Unit,
private val onCancelClick: () -> Unit
private val onCancelClick: () -> Unit,
private val isPrompt: Boolean = false
) : DialogFragment() {
private lateinit var binding: DialogPdfPasswordProtectionBinding
@ -47,6 +48,12 @@ class PdfPasswordProtectionDialogFragment(
binding.etPassword.showKeyboard()
if (isPrompt) {
binding.promptTv.visibility = View.VISIBLE
binding.promptTv.text = getString(R.string.file_is_password_protected, file.name)
} else {
binding.promptTv.visibility = View.GONE
}
binding.tvCancel.setOnClickListener {
onCancelClick()

View File

@ -193,8 +193,8 @@ object PdfUtils {
* @param onProgress 可选回调当前处理进度 (current 文件, total 文件)
* @return 新生成的 PDF 文件失败返回 null
*/
suspend fun mergePdfFilesSafe(
inputFiles: List<File>,
suspend fun mergePdfFilesWithPassword(
inputFiles: List<Pair<File, String?>>, // Pair<文件, 密码>
outputDir: File,
outputFileName: String,
onProgress: ((current: Int, total: Int) -> Unit)? = null
@ -209,9 +209,26 @@ object PdfUtils {
val merger = PDFMergerUtility()
val total = 100
// 加载文件 0~30%
inputFiles.forEachIndexed { index, file ->
merger.addSource(file)
// 0~30%: 加载 PDF
inputFiles.forEachIndexed { index, (file, password) ->
val doc = if (!password.isNullOrEmpty()) {
PDDocument.load(file, password)
} else {
PDDocument.load(file)
}
// 移除加密保护
if (doc.isEncrypted) {
doc.isAllSecurityToBeRemoved = true
}
// 将文档临时保存到内存文件,然后添加给 merger
val tempFile = File.createTempFile("merge_", ".pdf")
doc.save(tempFile)
doc.close()
merger.addSource(tempFile)
val progress = ((index + 1).toFloat() / inputFiles.size * 30).toInt()
onProgress?.invoke(progress, total)
delay(50)
@ -219,17 +236,18 @@ object PdfUtils {
merger.destinationFileName = outputFile.absolutePath
// 伪进度30%~95%
// 伪进度 30~95%
var fakeProgress = 30
val fakeJob = launch {
while (fakeProgress < 95) {
fakeProgress += (1..3).random() // 每次前进一点点
fakeProgress += (1..3).random()
if (fakeProgress > 95) fakeProgress = 95
onProgress?.invoke(fakeProgress, total)
delay((100L..300L).random()) // 模拟不均匀的进度
delay((100L..300L).random())
}
}
// 真正合并
merger.mergeDocuments(MemoryUsageSetting.setupTempFileOnly())
fakeJob.cancel()

View File

@ -9,20 +9,31 @@
<TextView
android:id="@+id/tvTitle"
style="@style/TextViewFont_PopSemiBold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/TextViewFont_PopSemiBold"
android:text="@string/password_protection"
android:textColor="@color/black"
android:textSize="20sp" />
<TextView
android:id="@+id/tvMessage"
android:id="@+id/promptTv"
style="@style/TextViewFont_PopMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/file_is_password_protected"
android:textColor="@color/black"
android:textSize="14sp"
android:visibility="gone" />
<TextView
android:id="@+id/tvMessage"
style="@style/TextViewFont_PopRegular"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/password_protection_dialog_desc"
android:textColor="@color/black_80"
android:textSize="14sp" />
@ -37,14 +48,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/enter_password"
app:endIconMode="password_toggle"
app:endIconDrawable="@drawable/dr_password_state">
app:endIconDrawable="@drawable/dr_password_state"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
style="@style/TextViewFont_PopRegular"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextViewFont_PopRegular"
android:inputType="textPassword"
android:textSize="16sp" />
@ -61,19 +72,19 @@
<TextView
android:id="@+id/tvCancel"
style="@style/TextViewFont_PopRegular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
style="@style/TextViewFont_PopRegular"
android:padding="12dp"
android:text="@string/cancel"
android:textColor="@color/black_80" />
<TextView
android:id="@+id/tvConfirm"
style="@style/TextViewFont_PopMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/TextViewFont_PopMedium"
android:padding="12dp"
android:text="@string/ok"
android:textColor="@color/black" />

View File

@ -21,6 +21,7 @@
<string name="created_date">Created Date</string>
<string name="path">Path</string>
<string name="path_details">Path: %1$s</string>
<string name="file_is_password_protected">%1$s is password protected</string>
<string name="file_name">File Name</string>
<string name="file_size">File Size</string>
<string name="ascending">Ascending</string>