1.设置密码

2.复制文件
3.优化耗时操作进度条展示
This commit is contained in:
ocean 2025-09-11 18:31:55 +08:00
parent 5323ad1324
commit aead309db7
17 changed files with 386 additions and 72 deletions

View File

@ -1,7 +1,15 @@
package com.all.pdfreader.pro.app.model
import java.io.File
sealed class FileActionEvent {
data class Rename(val renameResult: RenameResult) : FileActionEvent()
data class Delete(val deleteResult: DeleteResult) : FileActionEvent()
data class Favorite(val isFavorite: Boolean) : FileActionEvent()
data class Duplicate(val file: File?) : FileActionEvent()
// 成功或失败结果,只在完成事件时有值
data class SetPassword(val status: Status, val success: Boolean? = null) : FileActionEvent() {
enum class Status { START, COMPLETE }
}
}

View File

@ -9,6 +9,7 @@ interface PdfDocumentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(document: PdfDocumentEntity)
//@Update 不会响应flow
@Update
suspend fun update(document: PdfDocumentEntity)
@ -41,4 +42,6 @@ interface PdfDocumentDao {
@Query("UPDATE pdf_documents SET filePath = :newFilePath, fileName = :newName WHERE filePath = :oldFilePath")
suspend fun updateFilePathAndFileName(oldFilePath: String, newFilePath: String, newName: String)
@Query("UPDATE pdf_documents SET isPassword = :hasPassword WHERE filePath = :filePath")
suspend fun updateIsPassword(filePath: String, hasPassword: Boolean)
}

View File

@ -43,6 +43,11 @@ class PdfRepository private constructor(context: Context) {
pdfDao.updateFilePathAndFileName(oldFilePath, newFilePath, newName)
}
//语句更新可响应flow
suspend fun updateIsPassword(filePath: String, hasPassword: Boolean){
pdfDao.updateIsPassword(filePath, hasPassword)
}
suspend fun updateFavoriteStatus(filePath: String, isFavorite: Boolean) {
val document = pdfDao.getByPath(filePath)?.copy(
isFavorite = isFavorite,
@ -60,6 +65,7 @@ class PdfRepository private constructor(context: Context) {
document?.let { pdfDao.update(it) }
}
//复制更新不会更新flow
suspend fun updatePasswordStatus(filePath: String, isPassword: Boolean) {
val document = pdfDao.getByPath(filePath)?.copy(
isPassword = isPassword

View File

@ -12,6 +12,7 @@ import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.ActivityMainBinding
import com.all.pdfreader.pro.app.model.FileActionEvent
import com.all.pdfreader.pro.app.ui.dialog.PermissionDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.ProgressDialogFragment
import com.all.pdfreader.pro.app.ui.dialog.SortDialogFragment
import com.all.pdfreader.pro.app.ui.fragment.FavoriteFrag
import com.all.pdfreader.pro.app.ui.fragment.HomeFrag
@ -43,6 +44,8 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
private val viewModel by lazy { ViewModelProvider(this)[PdfViewModel::class.java] }
private var progressDialog: ProgressDialogFragment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
@ -93,6 +96,33 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
showToast(getString(R.string.removed_from_favorites))
}
}
is FileActionEvent.Duplicate -> {
if (event.file != null) {
showToast(getString(R.string.duplicate_created_successfully))
} else {
showToast(getString(R.string.duplicate_created_failed))
}
}
is FileActionEvent.SetPassword -> {
when (event.status) {
FileActionEvent.SetPassword.Status.START -> {
progressDialog = ProgressDialogFragment()
progressDialog?.show(supportFragmentManager, "progressDialog")
}
FileActionEvent.SetPassword.Status.COMPLETE -> {
progressDialog?.dismiss()
progressDialog = null
if (event.success == true) {
showToast(getString(R.string.set_password_successfully))
} else {
showToast(getString(R.string.set_password_failed))
}
}
}
}
}
}
}

View File

@ -74,6 +74,7 @@ class ListMoreDialogFragment(val filePath: String) : BottomSheetDialogFragment()
.into(binding.tvFileImg)
}
updateCollectUi(isFavorite)
updatePasswordUi(pdfDocument.isPassword)
}
private fun setupOnClick() {
@ -103,6 +104,18 @@ class ListMoreDialogFragment(val filePath: String) : BottomSheetDialogFragment()
printPdfFile(requireActivity(), Uri.fromFile(File(pdfDocument.filePath)))
dismiss()
}
binding.duplicateFileBtn.setOnClickListener {
viewModel.duplicateFile(requireActivity(), pdfDocument.filePath)
dismiss()
}
binding.setPasswordBtn.setOnClickListener {
if (pdfDocument.isPassword) {
} else {
PdfSetPasswordDialog().show(parentFragmentManager, "PdfSetPasswordDialog")
}
dismiss()
}
}
private fun updateCollectUi(b: Boolean) {
@ -113,6 +126,16 @@ class ListMoreDialogFragment(val filePath: String) : BottomSheetDialogFragment()
}
}
private fun updatePasswordUi(b: Boolean) {
if (b) {
binding.passwordIv.setImageResource(R.drawable.unlock)
binding.passwordTv.text = getString(R.string.remove_password)
} else {
binding.passwordIv.setImageResource(R.drawable.lock)
binding.passwordTv.text = getString(R.string.set_password)
}
}
private fun showToast(message: String) {
Toast.makeText(requireActivity(), message, Toast.LENGTH_SHORT).show()
}

View File

@ -7,18 +7,23 @@ import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.DialogPdfSetPasswordBinding
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.util.AppUtils.showKeyboard
import com.all.pdfreader.pro.app.viewmodel.PdfViewModel
import kotlin.getValue
class PdfSetPasswordDialog(
private val onCancelled: () -> Unit, private val onPasswordSet: (String) -> Unit
) : DialogFragment(
class PdfSetPasswordDialog() : DialogFragment(
) {
private lateinit var binding: DialogPdfSetPasswordBinding
private val viewModel: PdfViewModel by activityViewModels()
private lateinit var pdfDocument: PdfDocumentEntity
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
@ -42,14 +47,19 @@ class PdfSetPasswordDialog(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.etPassword.showKeyboard()
setupListeners()
setupTextWatchers()
viewModel.pdfDocument.value?.let {
pdfDocument = it
binding.etPassword.showKeyboard()
setupListeners()
setupTextWatchers()
} ?: run {
showToast(getString(R.string.file_not))
dismiss()
}
}
private fun setupListeners() {
binding.tvCancel.setOnClickListener {
onCancelled()
dismiss()
}
@ -57,7 +67,7 @@ class PdfSetPasswordDialog(
val password = binding.etPassword.text.toString()
val confirmPassword = binding.etConfirmPassword.text.toString()
if (validatePassword(password, confirmPassword)) {
onPasswordSet(password)
viewModel.setPassword(pdfDocument.filePath, password)
dismiss()
}
}
@ -118,4 +128,7 @@ class PdfSetPasswordDialog(
return true
}
private fun showToast(message: String) {
Toast.makeText(requireActivity(), message, Toast.LENGTH_SHORT).show()
}
}

View File

@ -0,0 +1,40 @@
package com.all.pdfreader.pro.app.ui.dialog
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.fragment.app.DialogFragment
import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.DialogProgressBinding
class ProgressDialogFragment : DialogFragment() {
private lateinit var binding: DialogProgressBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? {
binding = DialogProgressBinding.inflate(layoutInflater)
return binding.root
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
// 去掉系统默认的背景 padding
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
// 设置宽度为全屏减去 16dp
val margin = resources.getDimensionPixelSize(R.dimen.dialog_margin) // 16dp
val width = resources.displayMetrics.widthPixels - margin * 2
setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isCancelable = false
}
}

View File

@ -128,7 +128,6 @@ class RenameDialogFragment() : DialogFragment() {
return true
}
private fun showToast(message: String) {
Toast.makeText(requireActivity(), message, Toast.LENGTH_SHORT).show()
}

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.Bundle
@ -30,6 +31,7 @@ import java.io.FileOutputStream
import androidx.core.graphics.createBitmap
import androidx.print.PrintHelper
import com.all.pdfreader.pro.app.ui.adapter.PrintPdfAdapter
import com.shockwave.pdfium.PdfDocument
import com.shockwave.pdfium.PdfPasswordException
import com.shockwave.pdfium.PdfiumCore
import com.tom_roush.pdfbox.pdmodel.common.PDPageLabelRange
@ -167,4 +169,52 @@ object AppUtils {
e3.printStackTrace()
}
}
fun generateFastThumbnail(
context: Context,
pdfFile: File,
maxWidth: Int = 200,
maxHeight: Int = 300
): String? {
return try {
val pdfiumCore = PdfiumCore(context)
val fd = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
val pdfDocument: PdfDocument = pdfiumCore.newDocument(fd)
// 打开第一页
pdfiumCore.openPage(pdfDocument, 0)
// 获取原始页宽高
val width = pdfiumCore.getPageWidthPoint(pdfDocument, 0)
val height = pdfiumCore.getPageHeightPoint(pdfDocument, 0)
// 计算缩略图尺寸
val scale = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height)
val thumbWidth = (width * scale).toInt()
val thumbHeight = (height * scale).toInt()
val bitmap = createBitmap(thumbWidth, thumbHeight)
bitmap.eraseColor(Color.WHITE) // 白底
// 渲染第一页到 Bitmap
pdfiumCore.renderPageBitmap(pdfDocument, bitmap, 0, 0, 0, thumbWidth, thumbHeight)
// 保存到缓存
val cacheDir = File(context.cacheDir, "thumbnails")
if (!cacheDir.exists()) cacheDir.mkdirs()
val thumbFile = File(cacheDir, pdfFile.nameWithoutExtension + ".jpg")
FileOutputStream(thumbFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
pdfiumCore.closeDocument(pdfDocument)
fd.close()
thumbFile.absolutePath
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View File

@ -529,4 +529,29 @@ object FileUtils {
}
}
fun duplicateFile(originalFile: File): File? {
if (!originalFile.exists()) return null
val parentDir = originalFile.parentFile ?: return null
val fileName = originalFile.nameWithoutExtension
val extension = originalFile.extension
// 自动生成不重复的新文件名
var newFile = File(parentDir, "$fileName (copy).$extension")
var index = 1
while (newFile.exists()) {
newFile = File(parentDir, "$fileName ($index).$extension")
index++
}
// 执行复制
originalFile.inputStream().use { input ->
newFile.outputStream().use { output ->
input.copyTo(output)
}
}
return newFile
}
}

View File

@ -9,6 +9,7 @@ import androidx.core.graphics.createBitmap
import com.all.pdfreader.pro.app.PRApp
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.room.repository.PdfRepository
import com.all.pdfreader.pro.app.util.AppUtils.generateFastThumbnail
import com.all.pdfreader.pro.app.util.FileUtils.isPdfEncrypted
import com.shockwave.pdfium.PdfDocument
import com.shockwave.pdfium.PdfiumCore
@ -273,51 +274,4 @@ class PdfScanner(
val lastScan = getLastScanTime()
return TimeUnit.MILLISECONDS.toHours(System.currentTimeMillis() - lastScan)
}
private fun generateFastThumbnail(
context: Context,
pdfFile: File,
maxWidth: Int = 200,
maxHeight: Int = 300
): String? {
return try {
val pdfiumCore = PdfiumCore(context)
val fd = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
val pdfDocument: PdfDocument = pdfiumCore.newDocument(fd)
// 打开第一页
pdfiumCore.openPage(pdfDocument, 0)
// 获取原始页宽高
val width = pdfiumCore.getPageWidthPoint(pdfDocument, 0)
val height = pdfiumCore.getPageHeightPoint(pdfDocument, 0)
// 计算缩略图尺寸
val scale = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height)
val thumbWidth = (width * scale).toInt()
val thumbHeight = (height * scale).toInt()
val bitmap = createBitmap(thumbWidth, thumbHeight)
bitmap.eraseColor(Color.WHITE) // 白底
// 渲染第一页到 Bitmap
pdfiumCore.renderPageBitmap(pdfDocument, bitmap, 0, 0, 0, thumbWidth, thumbHeight)
// 保存到缓存
val cacheDir = File(context.cacheDir, "thumbnails")
if (!cacheDir.exists()) cacheDir.mkdirs()
val thumbFile = File(cacheDir, pdfFile.nameWithoutExtension + ".jpg")
FileOutputStream(thumbFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
pdfiumCore.closeDocument(pdfDocument)
fd.close()
thumbFile.absolutePath
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View File

@ -0,0 +1,44 @@
package com.all.pdfreader.pro.app.util
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.encryption.AccessPermission
import com.tom_roush.pdfbox.pdmodel.encryption.StandardProtectionPolicy
import java.io.File
object PdfSecurityUtils {
fun setPasswordToPdf(
inputFilePath: String,
userPassword: String,
ownerPassword: String
): Boolean {
return try {
PDDocument.load(File(inputFilePath)).use { document ->
val accessPermission = AccessPermission()
val protectionPolicy =
StandardProtectionPolicy(ownerPassword, userPassword, accessPermission)
protectionPolicy.encryptionKeyLength = 256
protectionPolicy.permissions = accessPermission
document.protect(protectionPolicy)
document.save(inputFilePath)
true
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun removePasswordFromPdf(inputFilePath: String, password: String): Boolean {
return try {
PDDocument.load(File(inputFilePath), password).use { document ->
document.isAllSecurityToBeRemoved = true // 移除所有安全设置,包括密码
document.save(inputFilePath)
}
true // 返回成功解密的标志
} catch (e: Exception) {
e.printStackTrace()
false // 返回解密失败的标志
}
}
}

View File

@ -1,5 +1,6 @@
package com.all.pdfreader.pro.app.viewmodel
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
@ -8,9 +9,12 @@ import androidx.lifecycle.viewModelScope
import com.all.pdfreader.pro.app.model.FileActionEvent
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.room.repository.PdfRepository
import com.all.pdfreader.pro.app.util.AppUtils.generateFastThumbnail
import com.all.pdfreader.pro.app.util.FileDeleteUtil
import com.all.pdfreader.pro.app.util.FileUtils
import com.all.pdfreader.pro.app.util.LogUtil
import com.all.pdfreader.pro.app.util.FileUtils.isPdfEncrypted
import com.all.pdfreader.pro.app.util.PdfMetadataExtractor
import com.all.pdfreader.pro.app.util.PdfSecurityUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -62,9 +66,7 @@ class PdfViewModel : ViewModel() {
pdfRepository.updateFilePathAndFileName(filePath, newFilePath, finalName)
}
withContext(Dispatchers.Main) {
_fileActionEvent.postValue(FileActionEvent.Rename(renameResult))
}
_fileActionEvent.postValue(FileActionEvent.Rename(renameResult))
}
}
@ -77,18 +79,76 @@ class PdfViewModel : ViewModel() {
Log.d("ocean", "文件已删除,清除数据库数据")
pdfRepository.deleteDocument(filePath)
}
withContext(Dispatchers.Main) {
_fileActionEvent.postValue(FileActionEvent.Delete(deleteResult))
}
_fileActionEvent.postValue(FileActionEvent.Delete(deleteResult))
}
}
fun saveCollectState(filePath: String, isFavorite: Boolean) {
viewModelScope.launch {
pdfRepository.updateFavoriteStatus(filePath, isFavorite)
withContext(Dispatchers.Main) {
_fileActionEvent.postValue(FileActionEvent.Favorite(isFavorite))
_fileActionEvent.postValue(FileActionEvent.Favorite(isFavorite))
}
}
fun duplicateFile(context: Context, filePath: String) {
viewModelScope.launch {
val file = FileUtils.duplicateFile(File(filePath))
Log.d("ocean", "duplicateFile->$file")
if (file != null) {
val isPassword = isPdfEncrypted(file)
val metadata = PdfMetadataExtractor.extractMetadata(file.absolutePath)
val document = PdfDocumentEntity(
filePath = file.absolutePath,
fileName = file.name,
fileSize = file.length(),
lastModified = file.lastModified(),
pageCount = metadata?.pageCount ?: 0,
thumbnailPath = null,
metadataTitle = metadata?.title,
metadataAuthor = metadata?.author,
metadataSubject = metadata?.subject,
metadataKeywords = metadata?.keywords,
metadataCreationDate = metadata?.creationDate?.time,
metadataModificationDate = metadata?.modificationDate?.time,
isPassword = isPassword
)
pdfRepository.insertOrUpdateDocument(document)
Log.d("ocean", "✅ 已保存到数据库: ${file.name}")
if (!isPassword) {//没有密码的情况下才去获取缩略图
launch(Dispatchers.IO) {
val newThumbnail = generateFastThumbnail(context, file)
if (newThumbnail != null && document.thumbnailPath != newThumbnail) {
pdfRepository.updateThumbnailPath(
document.filePath,
newThumbnail
)
Log.d("ocean", "✅ 缩略图已更新")
}
}
}
}
_fileActionEvent.postValue(FileActionEvent.Duplicate(file))
}
}
fun setPassword(filePath: String, password: String) {
viewModelScope.launch {
// 发送 START 事件UI 显示进度框
_fileActionEvent.postValue(FileActionEvent.SetPassword(FileActionEvent.SetPassword.Status.START))
val success = withContext(Dispatchers.IO) {
PdfSecurityUtils.setPasswordToPdf(filePath, password, password).also {
if (it) {
pdfRepository.updateIsPassword(filePath, true)
}
}
}
_fileActionEvent.postValue(
FileActionEvent.SetPassword(
FileActionEvent.SetPassword.Status.COMPLETE,
success
)
)
}
}
}

View File

@ -0,0 +1,20 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 背景轨道 -->
<item android:id="@android:id/background">
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#E0E0E0"/> <!-- 背景色 -->
</shape>
</item>
<!-- 动画前景 -->
<item android:id="@android:id/progress">
<clip>
<shape android:shape="rectangle">
<corners android:radius="6dp"/>
<solid android:color="#3F51B5"/> <!-- 动画颜色 -->
</shape>
</clip>
</item>
</layer-list>

View File

@ -230,6 +230,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/duplicateFileBtn"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
@ -251,17 +252,20 @@
</LinearLayout>
<LinearLayout
android:id="@+id/setPasswordBtn"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/passwordIv"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/lock" />
<TextView
android:id="@+id/passwordTv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/dr_rounded_corner_12_bg_white"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/poppins_medium"
android:gravity="center"
android:text="@string/processing"
android:textColor="@color/black"
android:textSize="18sp" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginTop="8dp"
android:indeterminate="true"/>
</LinearLayout>

View File

@ -49,7 +49,11 @@
<string name="delete_file">Delete File</string>
<string name="delete">Delete</string>
<string name="set_password">Set Password</string>
<string name="set_password_successfully">Set Password successfully</string>
<string name="set_password_failed">Set Password failed</string>
<string name="remove_password">Remove Password</string>
<string name="remove_password_successfully">Remove Password successfully</string>
<string name="remove_password_failed">Remove Password failed</string>
<string name="duplicate_file">Duplicate File</string>
<string name="enter_name">Enter a name</string>
<string name="name_not_empty">File name cannot be empty</string>
@ -88,4 +92,7 @@
<string name="pdf_cant_print_password_protected">Cannot print a password protected PDF file</string>
<string name="cannot_print_malformed_pdf">Cannot print a malformed PDF file</string>
<string name="pdf_cannot_print_error">Cannot print PDF file, Unknown error has occurred</string>
<string name="duplicate_created_successfully">Duplicate file created successfully</string>
<string name="duplicate_created_failed">Duplicate file created failed</string>
<string name="processing">Processing…</string>
</resources>