This commit is contained in:
ocean 2025-09-05 18:32:32 +08:00
parent daaa2e4de5
commit 810399bcf8
22 changed files with 926 additions and 146 deletions

View File

@ -3,24 +3,31 @@ package com.all.pdfreader.pro.app
import android.app.Application
import android.content.Context
import com.all.pdfreader.pro.app.room.repository.PdfRepository
import com.all.pdfreader.pro.app.util.FileChangeObserver
class PDFReaderApplication : Application() {
companion object {
private lateinit var instance: PDFReaderApplication
fun getInstance(): PDFReaderApplication = instance
fun getContext(): Context = instance.applicationContext
}
private lateinit var fileChangeObserver: FileChangeObserver
override fun onCreate() {
super.onCreate()
instance = this
// 初始化文件变化监听(不立即启动)
fileChangeObserver = FileChangeObserver(this)
// 初始化数据库
PdfRepository.initialize(this)
}
}
// 在权限授权后调用
fun startFileChangeObserving() {
fileChangeObserver.startObserving()
}
}

View File

@ -27,7 +27,10 @@ interface PdfDocumentDao {
@Query("SELECT * FROM pdf_documents ORDER BY lastOpenedTime DESC")
fun getAllDocuments(): Flow<List<PdfDocumentEntity>>
@Query("SELECT * FROM pdf_documents ORDER BY lastOpenedTime DESC")
suspend fun getAllDocumentsOnce(): List<PdfDocumentEntity>
@Delete
suspend fun delete(document: PdfDocumentEntity)

View File

@ -3,6 +3,7 @@ package com.all.pdfreader.pro.app.room.entity
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.all.pdfreader.pro.app.ui.dialog.PdfPasswordDialog
import kotlinx.parcelize.Parcelize
@Parcelize
@ -10,25 +11,28 @@ import kotlinx.parcelize.Parcelize
data class PdfDocumentEntity(
@PrimaryKey
val fileHash: String, // 文件内容哈希(MD5/SHA-1)
val filePath: String, // 当前文件路径
val fileName: String, // 文件名
val fileSize: Long, // 文件大小(字节)
val lastModified: Long, // 文件最后修改时间
val pageCount: Int, // 总页数
val isFavorite: Boolean = false, // 是否收藏
val addedToFavoriteTime: Long? = null, // 收藏时间
val lastOpenedTime: Long = 0, // 最后打开时间
val lastReadPage: Int = 0, // 最后阅读页码
val readingProgress: Float = 0f, // 阅读进度(0-100)
val thumbnailPath: String? = null, // 缩略图本地路径
val metadataTitle: String? = null, // PDF元数据标题
val metadataAuthor: String? = null, // PDF元数据作者
val metadataSubject: String? = null, // PDF元数据主题
val metadataKeywords: String? = null, // PDF元数据关键词
val metadataCreationDate: Long? = null, // PDF创建时间
val metadataModificationDate: Long? = null // PDF修改时间
): Parcelable
val metadataModificationDate: Long? = null, // PDF修改时间
val password: String? = null,// PDF密码加密存储
val isPassword: Boolean = false//是否存在密码
) : Parcelable

View File

@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.combine
import java.security.MessageDigest
class PdfRepository private constructor(context: Context) {
private val database = Room.databaseBuilder(
context,
PdfDatabase::class.java,
@ -34,9 +34,12 @@ class PdfRepository private constructor(context: Context) {
return pdfDao.getByPath(filePath)
}
suspend fun getAllDocumentsOnce(): List<PdfDocumentEntity> = pdfDao.getAllDocumentsOnce()
fun getAllDocuments(): Flow<List<PdfDocumentEntity>> = pdfDao.getAllDocuments()
fun getFavoriteDocuments(): Flow<List<PdfDocumentEntity>> = pdfDao.getFavoriteDocuments()
fun searchDocuments(query: String): Flow<List<PdfDocumentEntity>> = pdfDao.searchDocuments(query)
fun searchDocuments(query: String): Flow<List<PdfDocumentEntity>> =
pdfDao.searchDocuments(query)
suspend fun updateFavoriteStatus(fileHash: String, isFavorite: Boolean) {
val document = pdfDao.getByHash(fileHash)?.copy(
@ -55,13 +58,32 @@ class PdfRepository private constructor(context: Context) {
document?.let { pdfDao.update(it) }
}
suspend fun updatePasswordStatus(fileHash: String, isPassword: Boolean) {
val document = pdfDao.getByHash(fileHash)?.copy(
isPassword = isPassword
)
document?.let { pdfDao.update(it) }
}
suspend fun updatePassword(fileHash: String, password: String?) {
val document = pdfDao.getByHash(fileHash)?.copy(
password = password
)
document?.let { pdfDao.update(it) }
}
// 最近阅读相关操作
suspend fun addToRecent(pdfHash: String, page: Int = 0) {
val existing = recentDao.getByPdfHash(pdfHash)
if (existing != null) {
recentDao.updateOpenTime(pdfHash)
} else {
recentDao.insertOrUpdate(RecentReadEntity(pdfHash = pdfHash, lastOpenedTime = System.currentTimeMillis()))
recentDao.insertOrUpdate(
RecentReadEntity(
pdfHash = pdfHash,
lastOpenedTime = System.currentTimeMillis()
)
)
}
}
@ -71,21 +93,28 @@ class PdfRepository private constructor(context: Context) {
suspend fun addBookmark(bookmark: BookmarkEntity): Long = bookmarkDao.insert(bookmark)
suspend fun updateBookmark(bookmark: BookmarkEntity) = bookmarkDao.update(bookmark)
suspend fun deleteBookmark(bookmark: BookmarkEntity) = bookmarkDao.delete(bookmark)
fun getBookmarksByPdf(pdfHash: String): Flow<List<BookmarkEntity>> = bookmarkDao.getBookmarksByPdf(pdfHash)
suspend fun getBookmarksByPage(pdfHash: String, page: Int): List<BookmarkEntity> = bookmarkDao.getBookmarksByPage(pdfHash, page)
fun getBookmarksByPdf(pdfHash: String): Flow<List<BookmarkEntity>> =
bookmarkDao.getBookmarksByPdf(pdfHash)
suspend fun getBookmarksByPage(pdfHash: String, page: Int): List<BookmarkEntity> =
bookmarkDao.getBookmarksByPage(pdfHash, page)
// 注释相关操作
suspend fun addNote(note: NoteEntity): Long = noteDao.insert(note)
suspend fun updateNote(note: NoteEntity) = noteDao.update(note)
suspend fun deleteNote(note: NoteEntity) = noteDao.delete(note)
fun getNotesByPdf(pdfHash: String): Flow<List<NoteEntity>> = noteDao.getNotesByPdf(pdfHash)
suspend fun getNotesByPage(pdfHash: String, page: Int): List<NoteEntity> = noteDao.getNotesByPage(pdfHash, page)
fun getNotesByType(pdfHash: String, noteType: String): Flow<List<NoteEntity>> = noteDao.getNotesByType(pdfHash, noteType)
suspend fun getNotesByPage(pdfHash: String, page: Int): List<NoteEntity> =
noteDao.getNotesByPage(pdfHash, page)
fun getNotesByType(pdfHash: String, noteType: String): Flow<List<NoteEntity>> =
noteDao.getNotesByType(pdfHash, noteType)
// 组合查询
suspend fun getPdfWithDetails(pdfHash: String): Flow<PdfDetails> {
return combine(
pdfDao.getByHash(pdfHash)?.let { kotlinx.coroutines.flow.flowOf(it) } ?: kotlinx.coroutines.flow.flowOf(null),
pdfDao.getByHash(pdfHash)?.let { kotlinx.coroutines.flow.flowOf(it) }
?: kotlinx.coroutines.flow.flowOf(null),
bookmarkDao.getBookmarksByPdf(pdfHash),
noteDao.getNotesByPdf(pdfHash)
) { document, bookmarks, notes ->
@ -98,7 +127,7 @@ class PdfRepository private constructor(context: Context) {
return try {
val file = java.io.File(filePath)
if (!file.exists()) return ""
val digest = MessageDigest.getInstance("MD5")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
@ -137,7 +166,8 @@ class PdfRepository private constructor(context: Context) {
}
fun getInstance(): PdfRepository {
return INSTANCE ?: throw IllegalStateException("PdfRepository must be initialized first")
return INSTANCE
?: throw IllegalStateException("PdfRepository must be initialized first")
}
// 向后兼容的方法

View File

@ -6,6 +6,7 @@ import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.all.pdfreader.pro.app.PDFReaderApplication
import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.ActivityMainBinding
import com.all.pdfreader.pro.app.ui.dialog.PermissionDialogFragment
@ -31,7 +32,6 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
private lateinit var binding: ActivityMainBinding
private val pdfRepository = getRepository()
private lateinit var fileChangeObserver: FileChangeObserver
private lateinit var pdfScanner: PdfScanner
private val homeFragment = HomeFrag()
@ -64,23 +64,6 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
updateSelectedNav(activeFragment)
}
private fun scanningStrategy() {
// 智能扫描策略
if (pdfScanner.shouldScan()) {
logDebug("🔄 需要扫描PDF文件 (首次启动或超过24小时)")
if (StoragePermissionHelper.hasBasicStoragePermission(this)) {
lifecycleScope.launch {
pdfScanner.scanAndLoadPdfFiles()
}
} else {
logDebug("❌ 权限不足,跳过扫描")
}
} else {
val hoursAgo = pdfScanner.getHoursSinceLastScan()
logDebug("⏭️ 跳过扫描,上次扫描在${hoursAgo}小时前")
}
}
private fun setupFragments() {
supportFragmentManager.beginTransaction().add(R.id.fragment_fl, toolsFragment, "TOOLS")
.hide(toolsFragment).add(R.id.fragment_fl, favoriteFragment, "FAVORITE")
@ -161,7 +144,7 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
logDebug("main onResume")
if (StoragePermissionHelper.hasBasicStoragePermission(this)) {
// 有授权才初始化文件变化监听器
fileChangeObserver = FileChangeObserver(this, lifecycle)
PDFReaderApplication.getInstance().startFileChangeObserving()
scanningStrategy()
binding.pnLayout.visibility = View.GONE
} else {
@ -174,12 +157,29 @@ class MainActivity : BaseActivity(), PermissionDialogFragment.PermissionCallback
}
}
private fun scanningStrategy() {
// 智能扫描策略
if (pdfScanner.shouldScan()) {
logDebug("🔄 需要扫描PDF文件 (首次启动或超过24小时)")
if (StoragePermissionHelper.hasBasicStoragePermission(this)) {
lifecycleScope.launch {
pdfScanner.scanAndLoadPdfFiles()
}
} else {
logDebug("❌ 权限不足,跳过扫描")
}
} else {
val hoursAgo = pdfScanner.getHoursSinceLastScan()
logDebug("⏭️ 跳过扫描,上次扫描在${hoursAgo}小时前")
}
}
// 授权后续操作
override fun onPermissionGranted() {
logDebug("main onPermissionGranted")
//授权成功后:隐藏授权提示,开始扫描文件
binding.pnLayout.visibility = View.GONE
fileChangeObserver = FileChangeObserver(this, lifecycle)
PDFReaderApplication.getInstance().startFileChangeObserving()
scanningStrategy()
}

View File

@ -2,12 +2,14 @@ package com.all.pdfreader.pro.app.ui.act
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.all.pdfreader.pro.app.R
import com.all.pdfreader.pro.app.databinding.ActivityPdfViewBinding
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.ui.view.CustomScrollHandle
import com.all.pdfreader.pro.app.viewmodel.PdfViewModel
import com.github.barteksc.pdfviewer.listener.OnErrorListener
import com.github.barteksc.pdfviewer.listener.OnLoadCompleteListener
import com.github.barteksc.pdfviewer.listener.OnPageChangeListener
@ -17,38 +19,83 @@ import java.io.File
class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeListener,
OnErrorListener {
override val TAG: String = "PdfViewActivity"
companion object {
private const val EXTRA_PDF_HASH = "extra_pdf_hash"
// 创建启动Intent的便捷方法
fun createIntent(context: Context, filePath: String): Intent {
return Intent(context, PdfViewActivity::class.java).apply {
putExtra(EXTRA_PDF_HASH, filePath)
}
}
}
private lateinit var binding: ActivityPdfViewBinding
private lateinit var pdfDocument: PdfDocumentEntity
private val pdfRepository = getRepository()
private val viewModel by lazy { ViewModelProvider(this)[PdfViewModel::class.java] }
private val repository = getRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPdfViewBinding.inflate(layoutInflater)
setContentView(binding.root)
// 获取传递的PDF文档数据
pdfDocument = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_PDF_DOCUMENT, PdfDocumentEntity::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableExtra<PdfDocumentEntity>(EXTRA_PDF_DOCUMENT)
} ?: throw IllegalArgumentException("PDF document data is required")
val filePath = intent.getStringExtra(EXTRA_PDF_HASH)
?: throw IllegalArgumentException("PDF file hash is required")
loadPdf()
// 观察PDF文档数据
viewModel.pdfDocument.observe(this) { document ->
document?.let {
pdfDocument = it
loadPdf()
} ?: run {
showToast(getString(R.string.file_not))
finish()
}
}
// 加载PDF数据
viewModel.getPDFDocument(filePath)
}
private fun loadPdf() {
logDebug("loadPdf ->${pdfDocument.lastReadPage} ${pdfDocument.readingProgress}")
// 使用传递的文件路径加载PDF
val file = File(pdfDocument.filePath)
if (file.exists()) {
binding.pdfview
.fromFile(file)
val pdfView = binding.pdfview
// 如果有密码,先尝试使用密码加载
if (!pdfDocument.password.isNullOrEmpty()) {
try {
pdfView.fromFile(file)
.password(pdfDocument.password) // 使用密码加载
.defaultPage(pdfDocument.lastReadPage)
.enableDoubletap(true)
.onLoad(this)
.enableAnnotationRendering(true)
.onError(this)
.onPageChange(this)
.scrollHandle(CustomScrollHandle(this))
.load()
return
} catch (e: Exception) {
logDebug("Password protected PDF failed: ${e.message}")
// 密码错误,显示密码输入对话框
showPasswordDialog(file)
return
}
}
// 无密码PDF正常加载
pdfView.fromFile(file)
.defaultPage(pdfDocument.lastReadPage) // 从上次阅读页码开始
.enableDoubletap(true)// 是否允许双击缩放
.onLoad(this)
.enableAnnotationRendering(true) // 是否渲染注释(如评论、颜色、表单等)
.onError(this)
.onPageChange(this)
.scrollHandle(CustomScrollHandle(this))
.load()
} else {
showToast(getString(R.string.file_not) + ": ${pdfDocument.fileName}")
@ -63,38 +110,103 @@ class PdfViewActivity : BaseActivity(), OnLoadCompleteListener, OnPageChangeList
//PDF 加载出错时回调
override fun onError(t: Throwable?) {
logDebug("PDF loading error: ${t?.message}")
t?.let {
val errorMessage = it.message ?: "未知错误"
// 检查是否是密码相关的错误
if (errorMessage.contains("Password") || errorMessage.contains("password")) {
// 如果当前没有设置密码,显示密码输入对话框
if (pdfDocument.password.isNullOrEmpty()) {
val file = File(pdfDocument.filePath)
showPasswordDialog(file)
} else {
// 密码错误,显示密码输入对话框
showToast("PDF密码错误")
val file = File(pdfDocument.filePath)
showPasswordDialog(file)
}
} else {
// 其他错误
showToast("PDF加载失败: $errorMessage")
finish()
}
}
}
//页面切换时回调
override fun onPageChanged(page: Int, pageCount: Int) {
// 保存阅读进度
// 保存阅读进度到内存
pdfDocument = pdfDocument.copy(
lastReadPage = page, readingProgress = (page.toFloat() / pageCount.toFloat()) * 100
)
saveReadingProgress()
}
override fun onDestroy() {
super.onDestroy()
saveReadingProgress()
}
private fun saveReadingProgress() {
lifecycleScope.launch {
pdfRepository.updateReadingProgress(
repository.updateReadingProgress(
pdfDocument.fileHash, pdfDocument.lastReadPage, pdfDocument.readingProgress
)
}
}
companion object {
private const val EXTRA_PDF_DOCUMENT = "extra_pdf_document"
// 创建启动Intent的便捷方法
fun createIntent(context: Context, pdfDocument: PdfDocumentEntity): Intent {
return Intent(context, PdfViewActivity::class.java).apply {
putExtra(EXTRA_PDF_DOCUMENT, pdfDocument)
private fun showPasswordDialog(file: File) {
val builder = android.app.AlertDialog.Builder(this)
builder.setTitle("PDF密码保护")
builder.setMessage("请输入PDF文件密码")
val input = android.widget.EditText(this)
input.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
builder.setView(input)
builder.setPositiveButton("确定") { dialog, _ ->
val password = input.text.toString()
if (password.isNotEmpty()) {
// 尝试使用输入的密码重新加载PDF
tryLoadPdfWithPassword(file, password)
} else {
showToast("密码不能为空")
}
dialog.dismiss()
}
builder.setNegativeButton("取消") { dialog, _ ->
dialog.cancel()
finish()
}
builder.setOnCancelListener {
finish()
}
builder.show()
}
private fun tryLoadPdfWithPassword(file: File, password: String) {
try {
binding.pdfview.fromFile(file)
.password(password) // 使用输入的密码
.defaultPage(pdfDocument.lastReadPage)
.enableDoubletap(true)
.onLoad(this)
.enableAnnotationRendering(true)
.onError { error ->
logDebug("Password still incorrect: ${error?.message}")
showToast("密码错误,请重试")
showPasswordDialog(file) // 重新显示密码对话框
}
.onPageChange(this)
.scrollHandle(CustomScrollHandle(this))
.load()
} catch (e: Exception) {
logDebug("Failed to load PDF with provided password: ${e.message}")
showToast("密码错误,请重试")
showPasswordDialog(file) // 重新显示密码对话框
}
}
}

View File

@ -0,0 +1,132 @@
package com.all.pdfreader.pro.app.ui.dialog
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import com.all.pdfreader.pro.app.databinding.DialogPdfPasswordBinding
class PdfPasswordDialog : DialogFragment() {
private var _binding: DialogPdfPasswordBinding? = null
private val binding get() = _binding!!
private var listener: PasswordDialogListener? = null
interface PasswordDialogListener {
fun onPasswordSet(password: String?)
fun onPasswordDialogCancelled()
}
companion object {
fun newInstance(): PdfPasswordDialog {
return PdfPasswordDialog()
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = DialogPdfPasswordBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupListeners()
setupTextWatchers()
}
private fun setupListeners() {
binding.tvCancel.setOnClickListener {
listener?.onPasswordDialogCancelled()
dismiss()
}
binding.tvConfirm.setOnClickListener {
val password = binding.etPassword.text.toString()
val confirmPassword = binding.etConfirmPassword.text.toString()
if (validatePassword(password, confirmPassword)) {
listener?.onPasswordSet(password)
dismiss()
}
}
}
private fun setupTextWatchers() {
binding.etPassword.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
validateInput()
}
})
binding.etConfirmPassword.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
validateInput()
}
})
}
private fun validateInput() {
val password = binding.etPassword.text.toString()
val confirmPassword = binding.etConfirmPassword.text.toString()
binding.tilPassword.error = null
binding.tilConfirmPassword.error = null
if (password.isNotEmpty() && confirmPassword.isNotEmpty() && password != confirmPassword) {
binding.tilConfirmPassword.error = "密码不匹配"
}
}
private fun validatePassword(password: String, confirmPassword: String): Boolean {
binding.tilPassword.error = null
binding.tilConfirmPassword.error = null
// 允许空密码(表示不设置密码)
if (password.isEmpty()) {
return true
}
// 密码长度验证
if (password.length < 4) {
binding.tilPassword.error = "密码长度至少为4位"
return false
}
// 密码匹配验证
if (password != confirmPassword) {
binding.tilConfirmPassword.error = "密码不匹配"
return false
}
return true
}
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is PasswordDialogListener) {
listener = context
} else {
throw RuntimeException("$context must implement PasswordDialogListener")
}
}
override fun onDetach() {
super.onDetach()
listener = null
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -38,7 +38,7 @@ class HomeFrag : BaseFrag(), MainActivity.SortableFragment {
private fun initView() {
adapter = PdfAdapter(pdfList = mutableListOf(), onItemClick = { pdf ->
val intent = PdfViewActivity.createIntent(requireContext(), pdf)
val intent = PdfViewActivity.createIntent(requireContext(), pdf.filePath)
startActivity(intent)
}, onMoreClick = { pdf ->

View File

@ -0,0 +1,247 @@
package com.all.pdfreader.pro.app.ui.view;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.ViewGroup;
import android.widget.RelativeLayout;
import android.widget.TextView;
import com.all.pdfreader.pro.app.R;
import com.github.barteksc.pdfviewer.PDFView;
import com.github.barteksc.pdfviewer.scroll.ScrollHandle;
import com.github.barteksc.pdfviewer.util.Util;
import androidx.core.content.ContextCompat;
public class CustomScrollHandle extends RelativeLayout implements ScrollHandle {
private final static int HANDLE_LONG = 36;
private final static int HANDLE_SHORT = 28;
private final static int DEFAULT_TEXT_SIZE = 12;
private float relativeHandlerMiddle = 0f;
protected TextView textView;
protected Context context;
private final boolean inverted;
private PDFView pdfView;
private float currentPos;
private final Handler handler = new Handler();
private final Runnable hidePageScrollerRunnable = this::hide;
public CustomScrollHandle(Context context) {
this(context, false);
}
public CustomScrollHandle(Context context, boolean inverted) {
super(context);
this.context = context;
this.inverted = inverted;
textView = new TextView(context);
setVisibility(INVISIBLE);
setTextColor(Color.BLACK);
setTextSize(DEFAULT_TEXT_SIZE);
}
@Override
public void setupLayout(PDFView pdfView) {
int align, width, height;
Drawable background;
// determine handler position, default is right (when scrolling vertically) or bottom (when scrolling horizontally)
if (pdfView.isSwipeVertical()) {
width = HANDLE_LONG;
height = HANDLE_SHORT;
if (inverted) { // left
align = ALIGN_PARENT_LEFT;
background = ContextCompat.getDrawable(context, R.drawable.dr_scroll_handle_left);
} else { // right
align = ALIGN_PARENT_RIGHT;
background = ContextCompat.getDrawable(context, R.drawable.dr_scroll_handle_right);
}
} else {
width = HANDLE_SHORT;
height = HANDLE_LONG;
if (inverted) { // top
align = ALIGN_PARENT_TOP;
background = ContextCompat.getDrawable(context, R.drawable.dr_scroll_handle_top);
} else { // bottom
align = ALIGN_PARENT_BOTTOM;
background = ContextCompat.getDrawable(context, R.drawable.dr_scroll_handle_bottom);
}
}
setBackground(background);
LayoutParams lp = new LayoutParams(Util.getDP(context, width), Util.getDP(context, height));
lp.setMargins(0, 0, 0, 0);
LayoutParams tvlp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
tvlp.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
addView(textView, tvlp);
lp.addRule(align);
pdfView.addView(this, lp);
this.pdfView = pdfView;
}
@Override
public void destroyLayout() {
pdfView.removeView(this);
}
@Override
public void setScroll(float position) {
if (!shown()) {
show();
} else {
handler.removeCallbacks(hidePageScrollerRunnable);
}
if (pdfView != null) {
setPosition((pdfView.isSwipeVertical() ? pdfView.getHeight() : pdfView.getWidth()) * position);
}
}
private void setPosition(float pos) {
if (Float.isInfinite(pos) || Float.isNaN(pos)) {
return;
}
float pdfViewSize;
if (pdfView.isSwipeVertical()) {
pdfViewSize = pdfView.getHeight();
} else {
pdfViewSize = pdfView.getWidth();
}
pos -= relativeHandlerMiddle;
if (pos < 0) {
pos = 0;
} else if (pos > pdfViewSize - Util.getDP(context, HANDLE_SHORT)) {
pos = pdfViewSize - Util.getDP(context, HANDLE_SHORT);
}
if (pdfView.isSwipeVertical()) {
setY(pos);
} else {
setX(pos);
}
calculateMiddle();
invalidate();
}
private void calculateMiddle() {
float pos, viewSize, pdfViewSize;
if (pdfView.isSwipeVertical()) {
pos = getY();
viewSize = getHeight();
pdfViewSize = pdfView.getHeight();
} else {
pos = getX();
viewSize = getWidth();
pdfViewSize = pdfView.getWidth();
}
relativeHandlerMiddle = ((pos + relativeHandlerMiddle) / pdfViewSize) * viewSize;
}
@Override
public void hideDelayed() {
handler.postDelayed(hidePageScrollerRunnable, 1000);
}
@Override
public void setPageNum(int pageNum) {
String text = String.valueOf(pageNum);
if (!textView.getText().equals(text)) {
textView.setText(text);
}
}
@Override
public boolean shown() {
return getVisibility() == VISIBLE;
}
@Override
public void show() {
if (getVisibility() != VISIBLE) {
setTranslationX(getWidth()); // 初始在右侧外面
setAlpha(0f);
setVisibility(VISIBLE);
animate().translationX(0)
.alpha(1f)
.setDuration(450)
.start();
}
}
@Override
public void hide() {
if (getVisibility() == VISIBLE) {
animate().translationX(getWidth()) // 滑到右边外面
.alpha(0f)
.setDuration(450)
.withEndAction(() -> setVisibility(INVISIBLE))
.start();
}
}
public void setTextColor(int color) {
textView.setTextColor(color);
}
/**
* @param size text size in dp
*/
public void setTextSize(int size) {
textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, size);
}
private boolean isPDFViewReady() {
return pdfView != null && pdfView.getPageCount() > 0 && !pdfView.documentFitsView();
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!isPDFViewReady()) {
return super.onTouchEvent(event);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN:
pdfView.stopFling();
handler.removeCallbacks(hidePageScrollerRunnable);
if (pdfView.isSwipeVertical()) {
currentPos = event.getRawY() - getY();
} else {
currentPos = event.getRawX() - getX();
}
case MotionEvent.ACTION_MOVE:
if (pdfView.isSwipeVertical()) {
setPosition(event.getRawY() - currentPos + relativeHandlerMiddle);
pdfView.setPositionOffset(relativeHandlerMiddle / (float) getHeight(), false);
} else {
setPosition(event.getRawX() - currentPos + relativeHandlerMiddle);
pdfView.setPositionOffset(relativeHandlerMiddle / (float) getWidth(), false);
}
return true;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
hideDelayed();
pdfView.performPageSnap();
return true;
}
return super.onTouchEvent(event);
}
}

View File

@ -1,5 +1,3 @@
@file:Suppress("DEPRECATION")
package com.all.pdfreader.pro.app.util
import android.content.Context
@ -9,45 +7,35 @@ import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.collections.distinctBy
import kotlin.collections.plus
class FileChangeObserver(
private val context: Context,
private val lifecycle: Lifecycle
) : LifecycleObserver {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var contentObserver: ContentObserver? = null
private var isObserving = false
// 防抖机制,避免频繁触发
private var lastScanTime = 0L
private val debounceDelay = 3000L // 3秒防抖
init {
lifecycle.addObserver(this)
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun startObserving() {
if (isObserving) return
contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
handleFileChange()
}
}
try {
context.contentResolver.registerContentObserver(
MediaStore.Files.getContentUri("external"),
@ -60,11 +48,10 @@ class FileChangeObserver(
Log.e("ocean", "❌ 注册文件变化监听器失败", e)
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stopObserving() {
if (!isObserving) return
try {
contentObserver?.let { observer ->
context.contentResolver.unregisterContentObserver(observer)
@ -75,21 +62,21 @@ class FileChangeObserver(
Log.e("ocean", "❌ 注销文件变化监听器失败", e)
}
}
private fun handleFileChange() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastScanTime < debounceDelay) {
return // 防抖
}
lastScanTime = currentTime
scope.launch {
delay(debounceDelay) // 额外延迟,避免频繁扫描
if (StoragePermissionHelper.hasBasicStoragePermission(context)) {
Log.d("ocean", "📂 检测到文件变化,可以执行增量扫描...")
}
}
}

View File

@ -2,11 +2,14 @@ package com.all.pdfreader.pro.app.util
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.util.Log
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import java.text.SimpleDateFormat
@ -313,4 +316,32 @@ object FileUtils {
data class FileInfo(
val name: String, val size: Long, val uri: Uri
)
/**
* 判断 PDF 是否加密基于原生 PdfRenderer
*
* @param file 要检测的 PDF 文件
* @return true = 已加密 / 不能打开false = 未加密
*/
fun isPdfEncrypted(file: File): Boolean {
var fd: ParcelFileDescriptor? = null
var renderer: PdfRenderer? = null
return try {
fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
renderer = PdfRenderer(fd)
// 能成功打开 => 没有加密
false
} catch (e: SecurityException) {
// PdfRenderer 在遇到加密 PDF 时可能抛 SecurityException
true
} catch (e: IOException) {
// 某些加密 PDF 也可能走 IOException
e.message?.contains("password", ignoreCase = true) == true
} finally {
try {
renderer?.close()
fd?.close()
} catch (_: Exception) {}
}
}
}

View File

@ -7,42 +7,41 @@ import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.graphics.createBitmap
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.FileUtils.isPdfEncrypted
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap
import com.github.barteksc.pdfviewer.PDFView
import java.util.concurrent.CountDownLatch
class PdfScanner(
private val context: Context,
private val pdfRepository: PdfRepository
private val context: Context, private val pdfRepository: PdfRepository
) {
private val TAG = "ocean-PdfScanner"
suspend fun scanAndLoadPdfFiles(callback: (Boolean) -> Unit = {}) {
if (!StoragePermissionHelper.hasBasicStoragePermission(context)) {
LogUtil.logDebug("ocean", "PdfScanner: 权限不足")
LogUtil.logDebug(TAG, "PdfScanner: 权限不足")
callback.invoke(false)
return
}
withContext(Dispatchers.IO) {
try {
LogUtil.logDebug("ocean", "PdfScanner: 🔍 开始扫描PDF文件...")
LogUtil.logDebug(TAG, "PdfScanner: 🔍 开始扫描PDF文件...")
// 扫描应用私有目录(无需权限)
val privateFiles = FileUtils.scanPdfFiles(context)
LogUtil.logDebug(
"ocean",
"PdfScanner: 📁 应用私有目录找到: ${privateFiles.size} 个PDF文件"
TAG, "PdfScanner: 📁 应用私有目录找到: ${privateFiles.size} 个PDF文件"
)
privateFiles.forEach { file ->
LogUtil.logDebug(
"ocean",
TAG,
"PdfScanner: 📄 ${file.name} (${FileUtils.formatFileSize(file.length())})"
)
}
@ -50,41 +49,44 @@ class PdfScanner(
// 扫描MediaStore需要权限
val mediaStoreFiles = FileUtils.scanPdfFilesFromMediaStore(context)
LogUtil.logDebug(
"ocean",
"PdfScanner: 📱 MediaStore找到: ${mediaStoreFiles.size} 个PDF文件"
TAG, "PdfScanner: 📱 MediaStore找到: ${mediaStoreFiles.size} 个PDF文件"
)
mediaStoreFiles.forEach { file ->
LogUtil.logDebug(
"ocean",
TAG,
"PdfScanner: 📱 ${file.name} (${FileUtils.formatFileSize(file.length())})"
)
}
// 合并并去重
val allFiles = (privateFiles + mediaStoreFiles).distinctBy { it.absolutePath }
LogUtil.logDebug("ocean", "PdfScanner: 📊 总计扫描到: ${allFiles.size} 个PDF文件")
LogUtil.logDebug(TAG, "PdfScanner: 📊 总计扫描到: ${allFiles.size} 个PDF文件")
// 处理每个PDF文件
allFiles.forEachIndexed { index, file ->
LogUtil.logDebug(
"ocean",
"PdfScanner: 🔄 处理文件 ${index + 1}/${allFiles.size}: ${file.name}"
TAG,
"PdfScanner: 🔄 处理文件 ${index + 1}/${allFiles.size}: ${file.name} - ${file.absolutePath}"
)
if (FileUtils.isPdfFile(file)) {
val fileHash = FileUtils.calculateFileHash(file.absolutePath)
LogUtil.logDebug("ocean", "PdfScanner: 🔑 文件哈希: $fileHash")
LogUtil.logDebug(TAG, "PdfScanner: 🔑 文件哈希: $fileHash")
if (fileHash != null) {
val existingDoc = pdfRepository.getDocumentByHash(fileHash)
val existingDoc = pdfRepository.getDocumentByPath(file.absolutePath)
if (existingDoc == null) {
LogUtil.logDebug(
"ocean",
"PdfScanner: 🆕 发现新PDF文件: ${file.name}"
TAG, "PdfScanner: 🆕 发现新PDF文件: ${file.name}"
)
val thumbnailPath = generateThumbnail(context, file)
var thumbnailPath: String? = null
val isPassword = isPdfEncrypted(file)
LogUtil.logDebug(TAG, "PdfScanner: isPassword->${isPassword}")
if (!isPassword) {//没有密码的情况下才去获取缩略图
thumbnailPath = generateThumbnail(context, file) ?: ""
}
LogUtil.logDebug(TAG, "PdfScanner: thumbnailPath->${thumbnailPath}")
val metadata =
PdfMetadataExtractor.extractMetadata(file.absolutePath)
val document = PdfDocumentEntity(
@ -100,28 +102,60 @@ class PdfScanner(
metadataSubject = metadata?.subject,
metadataKeywords = metadata?.keywords,
metadataCreationDate = metadata?.creationDate?.time,
metadataModificationDate = metadata?.modificationDate?.time
metadataModificationDate = metadata?.modificationDate?.time,
isPassword = isPassword
)
pdfRepository.insertOrUpdateDocument(document)
LogUtil.logDebug(
"ocean",
"PdfScanner: ✅ 已保存到数据库: ${file.name}"
TAG, "PdfScanner: ✅ 已保存到数据库: ${file.name}"
)
} else {
LogUtil.logDebug(
"ocean",
"PdfScanner: 📋 文件已存在: ${file.name}"
)
if (existingDoc.filePath != file.absolutePath) {
LogUtil.logDebug(
"ocean",
"PdfScanner: 🔄 更新文件路径: ${existingDoc.filePath} -> ${file.absolutePath}"
)
val updatedDoc = existingDoc.copy(
LogUtil.logDebug(TAG, "PdfScanner: 📋 文件已存在: ${file.name}")
// 🔹 文件已存在,检查是否需要更新
var needUpdate = false
var updatedDoc = existingDoc.copy()
// 路径/修改时间更新
if (existingDoc.filePath != file.absolutePath || existingDoc.lastModified != file.lastModified()) {
LogUtil.logDebug(TAG, "PdfScanner: ✅ 路径/修改时间需要更新")
updatedDoc = updatedDoc.copy(
filePath = file.absolutePath,
lastModified = file.lastModified()
)
needUpdate = true
}
// 是否加密更新
val currentIsPassword = isPdfEncrypted(file)
if (existingDoc.isPassword != currentIsPassword) {
LogUtil.logDebug(TAG, "PdfScanner: ✅ 密码状态需要更新")
updatedDoc = updatedDoc.copy(isPassword = currentIsPassword)
needUpdate = true
}
if (!currentIsPassword) {
// 如果不是加密 PDF再生成缩略图
val newThumbnail = generateThumbnail(context, file)
if (existingDoc.thumbnailPath != newThumbnail) {
LogUtil.logDebug(TAG, "PdfScanner: ✅ 缩略图需要更新")
updatedDoc = updatedDoc.copy(thumbnailPath = newThumbnail)
needUpdate = true
}
} else {
updatedDoc = updatedDoc.copy(thumbnailPath = null)
needUpdate = true
}
// 执行更新
if (needUpdate) {
pdfRepository.insertOrUpdateDocument(updatedDoc)
LogUtil.logDebug(
TAG, "PdfScanner: ✅ 数据库已更新: ${file.name}"
)
} else {
LogUtil.logDebug(
TAG, "PdfScanner: ⏩ 无需更新: ${file.name}"
)
}
}
}
@ -129,31 +163,27 @@ class PdfScanner(
}
// 打印数据库中的总记录数
pdfRepository.getAllDocuments().collect { docs ->
LogUtil.logDebug("ocean", "PdfScanner: 📊 数据库中共有: ${docs.size} 个PDF记录")
docs.forEach { doc ->
LogUtil.logDebug(
"ocean",
"PdfScanner: 📖 ${doc.fileName} - ${doc.pageCount}页 - ${
FileUtils.formatFileSize(
doc.fileSize
)
} - ${doc.thumbnailPath}"
)
}
// 标记扫描完成
ScanManager.markScanComplete(context)
val lastScanTime = ScanManager.getLastScanTime(context)
pdfRepository.getAllDocumentsOnce().forEach { doc ->
LogUtil.logDebug(
"ocean",
"PdfScanner: ✅ 扫描完成,记录时间: ${java.util.Date(lastScanTime)}"
TAG, "PdfScanner: 📖 ${doc.fileName} - ${doc.pageCount}页 - ${
FileUtils.formatFileSize(
doc.fileSize
)
} - ${doc.thumbnailPath}"
)
callback.invoke(true)
}
// 标记扫描完成
ScanManager.markScanComplete(context)
val lastScanTime = ScanManager.getLastScanTime(context)
LogUtil.logDebug(
TAG, "PdfScanner: ✅ 扫描完成,记录时间: ${java.util.Date(lastScanTime)}"
)
callback.invoke(true)
} catch (e: Exception) {
Log.e("ocean", "PdfScanner: ❌ 扫描出错: ${e.message}", e)
Log.e(TAG, "PdfScanner: ❌ 扫描出错: ${e.message}", e)
callback.invoke(false)
}
}

View File

@ -0,0 +1,25 @@
package com.all.pdfreader.pro.app.viewmodel
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.all.pdfreader.pro.app.room.entity.PdfDocumentEntity
import com.all.pdfreader.pro.app.room.repository.PdfRepository
import kotlinx.coroutines.launch
class PdfViewModel : ViewModel() {
private val pdfRepository = PdfRepository.getInstance()
private val _pdfDocument = MutableLiveData<PdfDocumentEntity?>()
val pdfDocument: LiveData<PdfDocumentEntity?> = _pdfDocument
fun getPDFDocument(filePath: String) {
viewModelScope.launch {
val document = pdfRepository.getDocumentByPath(filePath)
Log.d("ocean", "getPDFDocument->loadPdf: filePath=$filePath, document=$document")
_pdfDocument.postValue(document)
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:bottomLeftRadius="16dp"
android:topLeftRadius="16dp" />
<solid android:color="@color/grey" />
</shape>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:color="#6C7A89"
android:width="1dp" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp" />
<solid android:color="#DADFE1" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/dr_scroll_handle_right"
android:fromDegrees="180"
android:toDegrees="180"
android:visible="true" />

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:color="#6C7A89"
android:width="1dp" />
<corners
android:bottomLeftRadius="16dp"
android:topLeftRadius="16dp" />
<solid android:color="#DADFE1" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/dr_scroll_handle_bottom"
android:fromDegrees="180"
android:toDegrees="180"
android:visible="true" />

View File

@ -6,6 +6,7 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/statusLayout"
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="center_vertical"
@ -41,4 +42,36 @@
</LinearLayout>
<LinearLayout
android:id="@+id/navigationLayout"
android:layout_width="match_parent"
android:layout_height="56dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/lockBtn"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/lockIv"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/lockTv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/poppins_regular"
android:text="@string/home"
android:textColor="@color/black"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@ -39,7 +39,7 @@
android:id="@+id/tvFileName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:ellipsize="middle"
android:maxLines="1"
android:text="@string/app_name"
android:textColor="@color/black"

View File

@ -0,0 +1,90 @@
<?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:orientation="vertical"
android:padding="24dp"
android:background="@android:color/white">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="设置PDF密码"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/tvMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="为PDF文件设置保护密码留空表示不设置密码"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="输入密码"
app:passwordToggleEnabled="true"
app:endIconMode="password_toggle"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilConfirmPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="确认密码"
app:passwordToggleEnabled="true"
app:endIconMode="password_toggle"
android:layout_marginBottom="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etConfirmPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<TextView
android:id="@+id/tvCancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="取消"
android:textColor="@color/black"
android:padding="12dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless" />
<TextView
android:id="@+id/tvConfirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="确认"
android:textColor="@color/black"
android:padding="12dp"
android:background="?attr/selectableItemBackgroundBorderless" />
</LinearLayout>
</LinearLayout>

View File

@ -24,4 +24,8 @@
<string name="permission_denied">Permission Denied</string>
<string name="pd_content_notice">Unable to access PDF file, please grant storage permission in settings</string>
<string name="file_not">File does not exist</string>
<string name="lock">Lock</string>
<string name="unlock">Unlock</string>
<string name="eye_protect">Eye Protect</string>
<string name="bookmarks">Bookmarks</string>
</resources>