Added - file encryption using room db.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
id("kotlin-kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,7 +13,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 5
|
||||
versionName = "1.3.1"
|
||||
versionName = "1.4.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -80,4 +81,9 @@ dependencies {
|
||||
implementation(libs.androidx.viewpager)
|
||||
implementation(libs.zoomage)
|
||||
implementation(libs.lottie)
|
||||
|
||||
// Room dependencies
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
kapt(libs.androidx.room.compiler)
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="devs.org.calculator.fileprovider"
|
||||
android:authorities="devs.org.calculator.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
||||
@@ -188,7 +188,6 @@ class HiddenActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun setupFlagSecure() {
|
||||
val prefs = getSharedPreferences("app_settings", MODE_PRIVATE)
|
||||
if (prefs.getBoolean("screenshot_restriction", true)) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
|
||||
@@ -15,6 +15,9 @@ import devs.org.calculator.utils.FileManager
|
||||
import devs.org.calculator.utils.PrefsUtil
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import devs.org.calculator.database.AppDatabase
|
||||
import devs.org.calculator.database.HiddenFileRepository
|
||||
import android.util.Log
|
||||
|
||||
class PreviewActivity : AppCompatActivity() {
|
||||
|
||||
@@ -27,7 +30,14 @@ class PreviewActivity : AppCompatActivity() {
|
||||
private lateinit var adapter: ImagePreviewAdapter
|
||||
private lateinit var fileManager: FileManager
|
||||
private val dialogUtil = DialogUtil(this)
|
||||
private val prefs:PrefsUtil by lazy { PrefsUtil(this) }
|
||||
private val prefs: PrefsUtil by lazy { PrefsUtil(this) }
|
||||
private val hiddenFileRepository: HiddenFileRepository by lazy {
|
||||
HiddenFileRepository(AppDatabase.getDatabase(this).hiddenFileDao())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PreviewActivity"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -141,8 +151,6 @@ class PreviewActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun setupClickListeners() {
|
||||
binding.back.setOnClickListener {
|
||||
finish()
|
||||
@@ -180,10 +188,18 @@ class PreviewActivity : AppCompatActivity() {
|
||||
override fun onPositiveButtonClicked() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// First delete from database
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(currentFile.absolutePath)
|
||||
hiddenFile?.let {
|
||||
hiddenFileRepository.deleteHiddenFile(it)
|
||||
Log.d(TAG, "Deleted file metadata from database: ${it.filePath}")
|
||||
}
|
||||
|
||||
// Then delete the actual file
|
||||
fileManager.deletePhotoFromExternalStorage(fileUri)
|
||||
removeFileFromList(currentPosition)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(TAG, "Error deleting file: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,10 +228,19 @@ class PreviewActivity : AppCompatActivity() {
|
||||
override fun onPositiveButtonClicked() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
fileManager.copyFileToNormalDir(fileUri)
|
||||
removeFileFromList(currentPosition)
|
||||
// First copy the file to normal directory
|
||||
val result = fileManager.copyFileToNormalDir(fileUri)
|
||||
if (result != null) {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(currentFile.absolutePath)
|
||||
hiddenFile?.let {
|
||||
hiddenFileRepository.deleteHiddenFile(it)
|
||||
Log.d(TAG, "Deleted file metadata from database: ${it.filePath}")
|
||||
}
|
||||
|
||||
removeFileFromList(currentPosition)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(TAG, "Error unhiding file: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
binding.screenshotRestrictionSwitch.isChecked = prefs.getBoolean("screenshot_restriction", true)
|
||||
binding.showFileNames.isChecked = prefs.getBoolean("showFileName", true)
|
||||
binding.encryptionSwitch.isChecked = prefs.getBoolean("encryption", false)
|
||||
|
||||
updateThemeModeVisibility()
|
||||
}
|
||||
@@ -79,6 +80,9 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.encryptionSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.setBoolean("encryption", isChecked)
|
||||
}
|
||||
|
||||
|
||||
binding.themeModeSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
@@ -20,19 +21,21 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import devs.org.calculator.R
|
||||
import devs.org.calculator.adapters.FileAdapter
|
||||
import devs.org.calculator.adapters.FolderSelectionAdapter
|
||||
import devs.org.calculator.callbacks.FileProcessCallback
|
||||
import devs.org.calculator.database.HiddenFileEntity
|
||||
import devs.org.calculator.databinding.ActivityViewFolderBinding
|
||||
import devs.org.calculator.databinding.ProccessingDialogBinding
|
||||
import devs.org.calculator.utils.DialogUtil
|
||||
import devs.org.calculator.utils.FileManager
|
||||
import devs.org.calculator.utils.FileManager.Companion.ENCRYPTED_EXTENSION
|
||||
import devs.org.calculator.utils.FileManager.Companion.HIDDEN_DIR
|
||||
import devs.org.calculator.utils.FolderManager
|
||||
import devs.org.calculator.utils.PrefsUtil
|
||||
import devs.org.calculator.utils.SecurityUtils
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@@ -84,8 +87,8 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
fileManager = FileManager(this, this)
|
||||
folderManager = FolderManager()
|
||||
dialogUtil = DialogUtil(this)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun setupActivityResultLaunchers() {
|
||||
pickImageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
@@ -163,6 +166,35 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
object : FileProcessCallback {
|
||||
override fun onFilesProcessedSuccessfully(copiedFiles: List<File>) {
|
||||
mainHandler.post {
|
||||
// Add files to Room database
|
||||
copiedFiles.forEach { file ->
|
||||
val fileType = fileManager.getFileType(file)
|
||||
var finalFile = file
|
||||
val extension = ".${file.extension}"
|
||||
val isEncrypted = prefs.getBoolean("encryption",false)
|
||||
|
||||
if (isEncrypted) {
|
||||
val encryptedFile = SecurityUtils.changeFileExtension(file, ENCRYPTED_EXTENSION)
|
||||
if (SecurityUtils.encryptFile(this@ViewFolderActivity, file, encryptedFile)) {
|
||||
finalFile = encryptedFile
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
fileAdapter?.hiddenFileRepository?.insertHiddenFile(
|
||||
HiddenFileEntity(
|
||||
filePath = finalFile.absolutePath,
|
||||
fileName = file.name,
|
||||
fileType = fileType,
|
||||
originalExtension = extension,
|
||||
isEncrypted = isEncrypted,
|
||||
encryptedFileName = finalFile.name
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.postDelayed({
|
||||
dismissCustomDialog()
|
||||
}, 1000)
|
||||
@@ -298,24 +330,54 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
private fun showFileOptionsMenu(selectedFiles: List<File>) {
|
||||
if (selectedFiles.isEmpty()) return
|
||||
|
||||
val options = arrayOf(
|
||||
getString(R.string.un_hide),
|
||||
getString(R.string.delete),
|
||||
getString(R.string.copy_to_another_folder),
|
||||
getString(R.string.move_to_another_folder)
|
||||
)
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.file_options))
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> unhideSelectedFiles(selectedFiles)
|
||||
1 -> deleteSelectedFiles(selectedFiles)
|
||||
2 -> copyToAnotherFolder(selectedFiles)
|
||||
3 -> moveToAnotherFolder(selectedFiles)
|
||||
lifecycleScope.launch {
|
||||
// Check if any files are encrypted
|
||||
var hasEncryptedFiles = false
|
||||
var hasDecryptedFiles = false
|
||||
|
||||
for (file in selectedFiles) {
|
||||
val hiddenFile = fileAdapter?.hiddenFileRepository?.getHiddenFileByPath(file.absolutePath)
|
||||
if (hiddenFile?.isEncrypted == true) {
|
||||
hasEncryptedFiles = true
|
||||
} else {
|
||||
hasDecryptedFiles = true
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
val options = mutableListOf(
|
||||
getString(R.string.un_hide),
|
||||
getString(R.string.delete),
|
||||
getString(R.string.copy_to_another_folder),
|
||||
getString(R.string.move_to_another_folder)
|
||||
)
|
||||
|
||||
// Add encryption/decryption options based on file status
|
||||
if (hasDecryptedFiles) {
|
||||
options.add(getString(R.string.encrypt_file))
|
||||
}
|
||||
if (hasEncryptedFiles) {
|
||||
options.add(getString(R.string.decrypt_file))
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(this@ViewFolderActivity)
|
||||
.setTitle(getString(R.string.file_options))
|
||||
.setItems(options.toTypedArray()) { _, which ->
|
||||
when (which) {
|
||||
0 -> unhideSelectedFiles(selectedFiles)
|
||||
1 -> deleteSelectedFiles(selectedFiles)
|
||||
2 -> copyToAnotherFolder(selectedFiles)
|
||||
3 -> moveToAnotherFolder(selectedFiles)
|
||||
else -> {
|
||||
val option = options[which]
|
||||
when (option) {
|
||||
getString(R.string.encrypt_file) -> fileAdapter?.encryptSelectedFiles()
|
||||
getString(R.string.decrypt_file) -> fileAdapter?.decryptSelectedFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveToAnotherFolder(selectedFiles: List<File>) {
|
||||
@@ -334,6 +396,7 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
object : DialogUtil.DialogCallback {
|
||||
override fun onPositiveButtonClicked() {
|
||||
performFileUnhiding(selectedFiles)
|
||||
|
||||
}
|
||||
|
||||
override fun onNegativeButtonClicked() {}
|
||||
@@ -367,20 +430,25 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshCurrentFolder() {
|
||||
currentFolder?.let { folder ->
|
||||
val files = folderManager.getFilesInFolder(folder)
|
||||
if (files.isNotEmpty()) {
|
||||
binding.recyclerView.visibility = View.VISIBLE
|
||||
binding.noItems.visibility = View.GONE
|
||||
fileAdapter?.submitList(files.toMutableList())
|
||||
fileAdapter?.let { adapter ->
|
||||
if (adapter.isInSelectionMode()) {
|
||||
showFileSelectionIcons()
|
||||
lifecycleScope.launch {
|
||||
val files = folderManager.getFilesInFolder(folder)
|
||||
mainHandler.post {
|
||||
if (files.isNotEmpty()) {
|
||||
binding.recyclerView.visibility = View.VISIBLE
|
||||
binding.noItems.visibility = View.GONE
|
||||
// Submit new list directly
|
||||
fileAdapter?.submitList(files.toMutableList())
|
||||
fileAdapter?.let { adapter ->
|
||||
if (adapter.isInSelectionMode()) {
|
||||
showFileSelectionIcons()
|
||||
} else {
|
||||
showFileViewIcons()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showFileViewIcons()
|
||||
showEmptyState()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showEmptyState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,58 +546,34 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
isFabOpen = false
|
||||
}
|
||||
|
||||
private fun performFileUnhiding(selectedFiles: List<File>) {
|
||||
private fun performFileDeletion(selectedFiles: List<File>) {
|
||||
lifecycleScope.launch {
|
||||
var allUnhidden = true
|
||||
var allDeleted = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val fileUri = FileManager.FileManager().getContentUriImage(this@ViewFolderActivity, file)
|
||||
|
||||
if (fileUri != null) {
|
||||
val result = fileManager.copyFileToNormalDir(fileUri)
|
||||
if (result == null) {
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
allUnhidden = false
|
||||
val hiddenFile = fileAdapter?.hiddenFileRepository?.getHiddenFileByPath(file.absolutePath)
|
||||
hiddenFile?.let {
|
||||
fileAdapter?.hiddenFileRepository?.deleteHiddenFile(it)
|
||||
}
|
||||
if (!file.delete()) {
|
||||
allDeleted = false
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
allUnhidden = false
|
||||
Log.e("ViewFolderActivity", "Error deleting file: ${e.message}")
|
||||
allDeleted = false
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
val message = if (allUnhidden) {
|
||||
getString(R.string.files_unhidden_successfully)
|
||||
} else {
|
||||
getString(R.string.some_files_could_not_be_unhidden)
|
||||
}
|
||||
|
||||
Toast.makeText(this@ViewFolderActivity, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
val message = if (allDeleted) {
|
||||
getString(R.string.files_deleted_successfully)
|
||||
} else {
|
||||
getString(R.string.some_items_could_not_be_deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performFileDeletion(selectedFiles: List<File>) {
|
||||
var allDeleted = true
|
||||
selectedFiles.forEach { file ->
|
||||
if (!file.delete()) {
|
||||
allDeleted = false
|
||||
}
|
||||
Toast.makeText(this@ViewFolderActivity, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
}
|
||||
|
||||
val message = if (allDeleted) {
|
||||
getString(R.string.files_deleted_successfully)
|
||||
} else {
|
||||
getString(R.string.some_items_could_not_be_deleted)
|
||||
}
|
||||
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
}
|
||||
|
||||
private fun copyToAnotherFolder(selectedFiles: List<File>) {
|
||||
@@ -539,38 +583,67 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun copyFilesToFolder(selectedFiles: List<File>, destinationFolder: File) {
|
||||
var allCopied = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val newFile = File(destinationFolder, file.name)
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
} catch (e: Exception) {
|
||||
allCopied = false
|
||||
lifecycleScope.launch {
|
||||
var allCopied = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val newFile = File(destinationFolder, file.name)
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
val hiddenFile = fileAdapter?.hiddenFileRepository?.getHiddenFileByPath(file.absolutePath)
|
||||
hiddenFile?.let {
|
||||
fileAdapter?.hiddenFileRepository?.insertHiddenFile(
|
||||
HiddenFileEntity(
|
||||
filePath = newFile.absolutePath,
|
||||
fileName = it.fileName,
|
||||
fileType = it.fileType,
|
||||
originalExtension = it.originalExtension,
|
||||
isEncrypted = it.isEncrypted,
|
||||
encryptedFileName = it.encryptedFileName
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ViewFolderActivity", "Error copying file: ${e.message}")
|
||||
allCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val message = if (allCopied) getString(R.string.files_copied_successfully) else getString(R.string.some_files_could_not_be_copied)
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
val message = if (allCopied) getString(R.string.files_copied_successfully) else getString(R.string.some_files_could_not_be_copied)
|
||||
Toast.makeText(this@ViewFolderActivity, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveFilesToFolder(selectedFiles: List<File>, destinationFolder: File) {
|
||||
var allMoved = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val newFile = File(destinationFolder, file.name)
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
file.delete()
|
||||
} catch (e: Exception) {
|
||||
allMoved = false
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
var allMoved = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val newFile = File(destinationFolder, file.name)
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
val hiddenFile = fileAdapter?.hiddenFileRepository?.getHiddenFileByPath(file.absolutePath)
|
||||
hiddenFile?.let {
|
||||
fileAdapter?.hiddenFileRepository?.updateEncryptionStatus(
|
||||
filePath = file.absolutePath,
|
||||
newFilePath = newFile.absolutePath,
|
||||
encryptedFileName = it.encryptedFileName,
|
||||
isEncrypted = it.isEncrypted
|
||||
)
|
||||
}
|
||||
|
||||
val message = if (allMoved) getString(R.string.files_moved_successfully) else getString(R.string.some_files_could_not_be_moved)
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
file.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.e("ViewFolderActivity", "Error moving file: ${e.message}")
|
||||
allMoved = false
|
||||
}
|
||||
}
|
||||
|
||||
val message = if (allMoved) getString(R.string.files_moved_successfully) else getString(R.string.some_files_could_not_be_moved)
|
||||
Toast.makeText(this@ViewFolderActivity, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFolderSelectionDialog(onFolderSelected: (File) -> Unit) {
|
||||
@@ -596,4 +669,80 @@ class ViewFolderActivity : AppCompatActivity() {
|
||||
|
||||
bottomSheetDialog.show()
|
||||
}
|
||||
|
||||
private fun performFileUnhiding(selectedFiles: List<File>) {
|
||||
lifecycleScope.launch {
|
||||
var allUnhidden = true
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
val hiddenFile = fileAdapter?.hiddenFileRepository?.getHiddenFileByPath(file.absolutePath)
|
||||
|
||||
if (hiddenFile?.isEncrypted == true) {
|
||||
val originalExtension = hiddenFile.originalExtension
|
||||
val decryptedFile = SecurityUtils.changeFileExtension(file, originalExtension)
|
||||
|
||||
if (SecurityUtils.decryptFile(this@ViewFolderActivity, file, decryptedFile)) {
|
||||
if (decryptedFile.exists() && decryptedFile.length() > 0) {
|
||||
val fileUri = FileManager.FileManager().getContentUriImage(this@ViewFolderActivity, decryptedFile)
|
||||
if (fileUri != null) {
|
||||
val result = fileManager.copyFileToNormalDir(fileUri)
|
||||
if (result != null) {
|
||||
hiddenFile.let {
|
||||
fileAdapter?.hiddenFileRepository?.deleteHiddenFile(it)
|
||||
}
|
||||
file.delete()
|
||||
decryptedFile.delete()
|
||||
} else {
|
||||
decryptedFile.delete()
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
decryptedFile.delete()
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
decryptedFile.delete()
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
if (decryptedFile.exists()) {
|
||||
decryptedFile.delete()
|
||||
}
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
val fileUri = FileManager.FileManager().getContentUriImage(this@ViewFolderActivity, file)
|
||||
if (fileUri != null) {
|
||||
val result = fileManager.copyFileToNormalDir(fileUri)
|
||||
if (result != null) {
|
||||
hiddenFile?.let {
|
||||
fileAdapter?.hiddenFileRepository?.deleteHiddenFile(it)
|
||||
}
|
||||
file.delete()
|
||||
} else {
|
||||
allUnhidden = false
|
||||
}
|
||||
} else {
|
||||
allUnhidden = false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ViewFolderActivity", "Error unhiding file: ${e.message}")
|
||||
allUnhidden = false
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
val message = if (allUnhidden) {
|
||||
getString(R.string.files_unhidden_successfully)
|
||||
} else {
|
||||
getString(R.string.some_files_could_not_be_unhidden)
|
||||
}
|
||||
|
||||
Toast.makeText(this@ViewFolderActivity, message, Toast.LENGTH_SHORT).show()
|
||||
fileAdapter?.exitSelectionMode()
|
||||
refreshCurrentFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
@@ -22,7 +23,14 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import devs.org.calculator.R
|
||||
import devs.org.calculator.activities.PreviewActivity
|
||||
import devs.org.calculator.database.AppDatabase
|
||||
import devs.org.calculator.database.HiddenFileEntity
|
||||
import devs.org.calculator.database.HiddenFileRepository
|
||||
import devs.org.calculator.utils.FileManager
|
||||
import devs.org.calculator.utils.SecurityUtils
|
||||
import devs.org.calculator.utils.SecurityUtils.getDecryptedPreviewFile
|
||||
import devs.org.calculator.utils.SecurityUtils.getUriForPreviewFile
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.Executors
|
||||
@@ -43,6 +51,10 @@ class FileAdapter(
|
||||
private val fileExecutor = Executors.newSingleThreadExecutor()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
val hiddenFileRepository: HiddenFileRepository by lazy {
|
||||
HiddenFileRepository(AppDatabase.getDatabase(context).hiddenFileDao())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FileAdapter"
|
||||
}
|
||||
@@ -65,17 +77,30 @@ class FileAdapter(
|
||||
val playIcon: ImageView = view.findViewById(R.id.videoPlay)
|
||||
val selectedLayer: View = view.findViewById(R.id.selectedLayer)
|
||||
val selected: ImageView = view.findViewById(R.id.selected)
|
||||
val encryptedIcon: ImageView = view.findViewById(R.id.encrypted)
|
||||
|
||||
fun bind(file: File) {
|
||||
val fileType = FileManager(context, lifecycleOwner).getFileType(file)
|
||||
setupFileDisplay(file, fileType)
|
||||
setupClickListeners(file, fileType)
|
||||
fileNameTextView.visibility = if (showFileName) View.VISIBLE else View.GONE
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(file.absolutePath)
|
||||
val fileType = if (hiddenFile?.fileType != null) hiddenFile.fileType
|
||||
else {
|
||||
FileManager(context, lifecycleOwner).getFileType(file)
|
||||
}
|
||||
|
||||
setupFileDisplay(file, fileType, hiddenFile?.isEncrypted == true,hiddenFile)
|
||||
setupClickListeners(file, fileType)
|
||||
fileNameTextView.visibility = if (showFileName) View.VISIBLE else View.GONE
|
||||
|
||||
val position = adapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
val isSelected = selectedItems.contains(position)
|
||||
updateSelectionUI(isSelected)
|
||||
val position = adapterPosition
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
val isSelected = selectedItems.contains(position)
|
||||
updateSelectionUI(isSelected)
|
||||
}
|
||||
encryptedIcon.visibility = if (hiddenFile?.isEncrypted == true) View.VISIBLE else View.GONE
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error binding file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,37 +136,100 @@ class FileAdapter(
|
||||
selected.visibility = if (isSelected) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun setupFileDisplay(file: File, fileType: FileManager.FileType) {
|
||||
fileNameTextView.text = file.name
|
||||
private fun setupFileDisplay(file: File, fileType: FileManager.FileType, isEncrypted: Boolean, metadata: HiddenFileEntity?) {
|
||||
fileNameTextView.text = metadata?.fileName ?: file.name
|
||||
|
||||
when (fileType) {
|
||||
FileManager.FileType.IMAGE -> {
|
||||
playIcon.visibility = View.GONE
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.error(R.drawable.ic_document)
|
||||
.into(imageView)
|
||||
if (isEncrypted) {
|
||||
try {
|
||||
val decryptedFile = getDecryptedPreviewFile(context, metadata!!)
|
||||
if (decryptedFile != null && decryptedFile.exists() && decryptedFile.length() > 0) {
|
||||
val uri = getUriForPreviewFile(context, decryptedFile)
|
||||
if (uri != null) {
|
||||
Glide.with(context)
|
||||
.load(uri)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.error(R.drawable.encrypted)
|
||||
.into(imageView)
|
||||
imageView.setPadding(0, 0, 0, 0)
|
||||
} else {
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} else {
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading encrypted image preview: ${e.message}")
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} else {
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.error(R.drawable.ic_image)
|
||||
.into(imageView)
|
||||
imageView.setPadding(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
FileManager.FileType.VIDEO -> {
|
||||
playIcon.visibility = View.VISIBLE
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.error(R.drawable.ic_document)
|
||||
.into(imageView)
|
||||
if (isEncrypted) {
|
||||
try {
|
||||
val decryptedFile = getDecryptedPreviewFile(context, metadata!!)
|
||||
if (decryptedFile != null && decryptedFile.exists() && decryptedFile.length() > 0) {
|
||||
val uri = getUriForPreviewFile(context, decryptedFile)
|
||||
if (uri != null) {
|
||||
Glide.with(context)
|
||||
.load(uri)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.error(R.drawable.encrypted)
|
||||
.into(imageView)
|
||||
imageView.setPadding(0, 0, 0, 0)
|
||||
} else {
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} else {
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading encrypted video preview: ${e.message}")
|
||||
showEncryptedIcon()
|
||||
}
|
||||
} else {
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.centerCrop()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.error(R.drawable.ic_video)
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
FileManager.FileType.AUDIO -> {
|
||||
playIcon.visibility = View.GONE
|
||||
imageView.setImageResource(R.drawable.ic_audio)
|
||||
imageView.setPadding(50,50,50,50)
|
||||
if (isEncrypted) {
|
||||
imageView.setImageResource(R.drawable.encrypted)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.ic_audio)
|
||||
}
|
||||
imageView.setPadding(50, 50, 50, 50)
|
||||
}
|
||||
else -> {
|
||||
playIcon.visibility = View.GONE
|
||||
imageView.setImageResource(R.drawable.ic_document)
|
||||
imageView.setPadding(50,50,50,50)
|
||||
if (isEncrypted) {
|
||||
imageView.setImageResource(R.drawable.encrypted)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.ic_document)
|
||||
}
|
||||
imageView.setPadding(50, 50, 50, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +266,56 @@ class FileAdapter(
|
||||
|
||||
when (fileType) {
|
||||
FileManager.FileType.AUDIO -> openAudioFile(file)
|
||||
FileManager.FileType.IMAGE, FileManager.FileType.VIDEO -> openInPreview(fileType)
|
||||
FileManager.FileType.IMAGE, FileManager.FileType.VIDEO -> {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(file.absolutePath)
|
||||
if (hiddenFile?.isEncrypted == true) {
|
||||
val tempFile = File(context.cacheDir, "preview_${file.name}")
|
||||
Log.d(TAG, "Attempting to decrypt file for preview: ${file.absolutePath}")
|
||||
|
||||
if (SecurityUtils.decryptFile(context, file, tempFile)) {
|
||||
Log.d(TAG, "Successfully decrypted file for preview: ${tempFile.absolutePath}")
|
||||
if (tempFile.exists() && tempFile.length() > 0) {
|
||||
mainHandler.post {
|
||||
val fileTypeString = when (fileType) {
|
||||
FileManager.FileType.IMAGE -> context.getString(R.string.image)
|
||||
FileManager.FileType.VIDEO -> context.getString(R.string.video)
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
val intent = Intent(context, PreviewActivity::class.java).apply {
|
||||
putExtra("type", fileTypeString)
|
||||
putExtra("folder", currentFolder.toString())
|
||||
putExtra("position", adapterPosition)
|
||||
putExtra("isEncrypted", true)
|
||||
putExtra("tempFile", tempFile.absolutePath)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Decrypted preview file is empty or doesn't exist")
|
||||
mainHandler.post {
|
||||
Toast.makeText(context, "Failed to prepare file for preview", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to decrypt file for preview")
|
||||
mainHandler.post {
|
||||
Toast.makeText(context, "Failed to decrypt file for preview", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openInPreview(fileType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error preparing file for preview: ${e.message}", e)
|
||||
mainHandler.post {
|
||||
Toast.makeText(context, "Error preparing file for preview", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FileManager.FileType.DOCUMENT -> openDocumentFile(file)
|
||||
else -> openDocumentFile(file)
|
||||
}
|
||||
@@ -247,101 +384,6 @@ class FileAdapter(
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun showFileOptionsDialog(file: File) {
|
||||
val options = if (isSelectionMode) {
|
||||
arrayOf(
|
||||
context.getString(R.string.un_hide),
|
||||
context.getString(R.string.delete),
|
||||
context.getString(R.string.copy_to_another_folder),
|
||||
context.getString(R.string.move_to_another_folder)
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
context.getString(R.string.un_hide),
|
||||
context.getString(R.string.select_multiple),
|
||||
context.getString(R.string.rename),
|
||||
context.getString(R.string.delete),
|
||||
context.getString(R.string.share)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(context.getString(R.string.file_options))
|
||||
.setItems(options) { dialog, which ->
|
||||
if (isSelectionMode) {
|
||||
when (which) {
|
||||
0 -> unHideFile(file)
|
||||
1 -> deleteFile(file)
|
||||
2 -> copyToAnotherFolder(file)
|
||||
3 -> moveToAnotherFolder(file)
|
||||
}
|
||||
} else {
|
||||
when (which) {
|
||||
0 -> unHideFile(file)
|
||||
1 -> enableSelectMultipleFiles()
|
||||
2 -> renameFile(file)
|
||||
3 -> deleteFile(file)
|
||||
4 -> shareFile(file)
|
||||
}
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun enableSelectMultipleFiles() {
|
||||
val position = adapterPosition
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
|
||||
enterSelectionMode()
|
||||
selectedItems.add(position)
|
||||
notifyItemChanged(position, listOf("SELECTION_CHANGED"))
|
||||
}
|
||||
|
||||
private fun unHideFile(file: File) {
|
||||
FileManager(context, lifecycleOwner).unHideFile(
|
||||
file = file,
|
||||
onSuccess = {
|
||||
fileOperationCallback?.get()?.onFileDeleted(file)
|
||||
},
|
||||
onError = { errorMessage ->
|
||||
Toast.makeText(context, "Failed to unhide: $errorMessage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteFile(file: File) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("Delete File")
|
||||
.setMessage("Are you sure you want to delete ${file.name}?")
|
||||
.setPositiveButton("Delete") { _, _ ->
|
||||
deleteFileAsync(file)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteFileAsync(file: File) {
|
||||
fileExecutor.execute {
|
||||
val success = try {
|
||||
file.delete()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete file: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
if (success) {
|
||||
fileOperationCallback?.get()?.onFileDeleted(file)
|
||||
Toast.makeText(context, "File deleted", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to delete file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingInflatedId")
|
||||
private fun renameFile(file: File) {
|
||||
val dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_input, null)
|
||||
@@ -409,34 +451,6 @@ class FileAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareFile(file: File) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = context.contentResolver.getType(uri) ?: "*/*"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(shareIntent, context.getString(R.string.share_file))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to share file: ${e.message}")
|
||||
Toast.makeText(context, "Failed to share file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToAnotherFolder(file: File) {
|
||||
fileOperationCallback?.get()?.onRefreshNeeded()
|
||||
}
|
||||
|
||||
private fun moveToAnotherFolder(file: File) {
|
||||
fileOperationCallback?.get()?.onRefreshNeeded()
|
||||
}
|
||||
|
||||
private fun toggleSelection(position: Int) {
|
||||
if (selectedItems.contains(position)) {
|
||||
@@ -450,6 +464,11 @@ class FileAdapter(
|
||||
onSelectionCountChanged(selectedItems.size)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
private fun showEncryptedIcon() {
|
||||
imageView.setImageResource(R.drawable.encrypted)
|
||||
imageView.setPadding(50, 50, 50, 50)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
|
||||
@@ -482,7 +501,7 @@ class FileAdapter(
|
||||
currentList.clear()
|
||||
super.submitList(null)
|
||||
} else {
|
||||
val newList = list.toMutableList()
|
||||
val newList = list.filter { it.name != ".nomedia" }.toMutableList()
|
||||
super.submitList(newList)
|
||||
}
|
||||
}
|
||||
@@ -521,19 +540,12 @@ class FileAdapter(
|
||||
if (!isSelectionMode) {
|
||||
enterSelectionMode()
|
||||
}
|
||||
|
||||
val previouslySelected = selectedItems.toSet()
|
||||
selectedItems.clear()
|
||||
|
||||
// Add all positions to selection
|
||||
for (i in 0 until itemCount) {
|
||||
selectedItems.add(i)
|
||||
}
|
||||
|
||||
// Notify callback about selection change
|
||||
fileOperationCallback?.get()?.onSelectionCountChanged(selectedItems.size)
|
||||
|
||||
// Update UI for changed items efficiently
|
||||
updateSelectionItems(selectedItems.toSet(), previouslySelected)
|
||||
}
|
||||
|
||||
@@ -564,122 +576,6 @@ class FileAdapter(
|
||||
fun isInSelectionMode(): Boolean = isSelectionMode
|
||||
|
||||
|
||||
fun deleteSelectedFiles() {
|
||||
val selectedFiles = getSelectedItems()
|
||||
if (selectedFiles.isEmpty()) return
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("Delete Files")
|
||||
.setMessage("Are you sure you want to delete ${selectedFiles.size} file(s)?")
|
||||
.setPositiveButton("Delete") { _, _ ->
|
||||
deleteFilesAsync(selectedFiles)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteFilesAsync(selectedFiles: List<File>) {
|
||||
fileExecutor.execute {
|
||||
var deletedCount = 0
|
||||
var failedCount = 0
|
||||
val failedFiles = mutableListOf<String>()
|
||||
|
||||
selectedFiles.forEach { file ->
|
||||
try {
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
mainHandler.post {
|
||||
fileOperationCallback?.get()?.onFileDeleted(file)
|
||||
}
|
||||
} else {
|
||||
failedCount++
|
||||
failedFiles.add(file.name)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failedCount++
|
||||
failedFiles.add(file.name)
|
||||
Log.e(TAG, "Failed to delete ${file.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
exitSelectionMode()
|
||||
|
||||
when {
|
||||
deletedCount > 0 && failedCount == 0 -> {
|
||||
Toast.makeText(context, "Deleted $deletedCount file(s)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
deletedCount > 0 && failedCount > 0 -> {
|
||||
Toast.makeText(context,
|
||||
"Deleted $deletedCount file(s), failed to delete $failedCount",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
failedCount > 0 -> {
|
||||
Toast.makeText(context,
|
||||
"Failed to delete $failedCount file(s)",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun shareSelectedFiles() {
|
||||
val selectedFiles = getSelectedItems()
|
||||
if (selectedFiles.isEmpty()) return
|
||||
|
||||
try {
|
||||
if (selectedFiles.size == 1) {
|
||||
val file = selectedFiles.first()
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = context.contentResolver.getType(uri) ?: "*/*"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(shareIntent, context.getString(R.string.share_file))
|
||||
)
|
||||
} else {
|
||||
val uris = selectedFiles.mapNotNull { file ->
|
||||
try {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get URI for file ${file.name}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (uris.isNotEmpty()) {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||
type = "*/*"
|
||||
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(shareIntent, "Share ${selectedFiles.size} files")
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(context, "No files could be shared", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to share files: ${e.message}")
|
||||
Toast.makeText(context, "Failed to share files", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
exitSelectionMode()
|
||||
}
|
||||
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
return if (isSelectionMode) {
|
||||
exitSelectionMode()
|
||||
@@ -689,17 +585,6 @@ class FileAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSelectionStates() {
|
||||
if (isSelectionMode) {
|
||||
selectedItems.forEach { position ->
|
||||
if (position < itemCount) {
|
||||
notifyItemChanged(position, listOf("SELECTION_CHANGED"))
|
||||
}
|
||||
}
|
||||
notifySelectionModeChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
try {
|
||||
if (!fileExecutor.isShutdown) {
|
||||
@@ -720,4 +605,171 @@ class FileAdapter(
|
||||
private fun onSelectionCountChanged(count: Int) {
|
||||
fileOperationCallback?.get()?.onSelectionCountChanged(count)
|
||||
}
|
||||
|
||||
fun encryptSelectedFiles() {
|
||||
val selectedFiles = getSelectedItems()
|
||||
if (selectedFiles.isEmpty()) return
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(context.getString(R.string.encrypt_files))
|
||||
.setMessage(context.getString(R.string.encryption_disclaimer))
|
||||
.setPositiveButton(context.getString(R.string.encrypt)) { _, _ ->
|
||||
performEncryption(selectedFiles)
|
||||
}
|
||||
.setNegativeButton(context.getString(R.string.cancel), null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performEncryption(selectedFiles: List<File>) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
val updatedFiles = mutableListOf<File>()
|
||||
|
||||
for (file in selectedFiles) {
|
||||
try {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(file.absolutePath)
|
||||
if (hiddenFile?.isEncrypted == true) continue
|
||||
val originalExtension = ".${file.extension.lowercase()}"
|
||||
val fileType = FileManager(context,lifecycleOwner).getFileType(file)
|
||||
val encryptedFile = SecurityUtils.changeFileExtension(file, FileManager.ENCRYPTED_EXTENSION)
|
||||
if (SecurityUtils.encryptFile(context, file, encryptedFile)) {
|
||||
if (encryptedFile.exists()) {
|
||||
if (hiddenFile == null){
|
||||
hiddenFileRepository.insertHiddenFile(
|
||||
HiddenFileEntity(
|
||||
filePath = encryptedFile.absolutePath,
|
||||
isEncrypted = true,
|
||||
encryptedFileName = encryptedFile.name,
|
||||
fileType = fileType,
|
||||
fileName = file.name,
|
||||
originalExtension = originalExtension
|
||||
)
|
||||
)
|
||||
}else{
|
||||
hiddenFile.let {
|
||||
hiddenFileRepository.updateEncryptionStatus(
|
||||
filePath = hiddenFile.filePath,
|
||||
newFilePath = encryptedFile.absolutePath,
|
||||
encryptedFileName = encryptedFile.name,
|
||||
isEncrypted = true
|
||||
)
|
||||
}
|
||||
}
|
||||
if (file.delete()) {
|
||||
updatedFiles.add(encryptedFile)
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error encrypting file: ${e.message}")
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
exitSelectionMode()
|
||||
when {
|
||||
successCount > 0 && failCount == 0 -> {
|
||||
Toast.makeText(context, "Encrypted $successCount file(s)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
successCount > 0 && failCount > 0 -> {
|
||||
Toast.makeText(context, "Encrypted $successCount file(s), failed to encrypt $failCount", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
failCount > 0 -> {
|
||||
Toast.makeText(context, "Failed to encrypt $failCount file(s)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val currentFiles = currentFolder.listFiles()?.toList() ?: emptyList()
|
||||
submitList(currentFiles)
|
||||
fileOperationCallback?.get()?.onRefreshNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptSelectedFiles() {
|
||||
val selectedFiles = getSelectedItems()
|
||||
if (selectedFiles.isEmpty()) return
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(context.getString(R.string.decrypt_files))
|
||||
.setMessage(context.getString(R.string.decryption_disclaimer))
|
||||
.setPositiveButton(context.getString(R.string.decrypt)) { _, _ ->
|
||||
performDecryption(selectedFiles)
|
||||
}
|
||||
.setNegativeButton(context.getString(R.string.cancel), null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performDecryption(selectedFiles: List<File>) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
val updatedFiles = mutableListOf<File>()
|
||||
|
||||
for (file in selectedFiles) {
|
||||
try {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(file.absolutePath)
|
||||
if (hiddenFile?.isEncrypted != true) continue
|
||||
val originalExtension = hiddenFile.originalExtension
|
||||
val decryptedFile = SecurityUtils.changeFileExtension(file, originalExtension)
|
||||
if (SecurityUtils.decryptFile(context, file, decryptedFile)) {
|
||||
if (decryptedFile.exists() && decryptedFile.length() > 0) {
|
||||
hiddenFile.let {
|
||||
hiddenFileRepository.updateEncryptionStatus(
|
||||
filePath = file.absolutePath,
|
||||
newFilePath = decryptedFile.absolutePath,
|
||||
encryptedFileName = decryptedFile.name,
|
||||
isEncrypted = false
|
||||
)
|
||||
}
|
||||
if (file.delete()) {
|
||||
updatedFiles.add(decryptedFile)
|
||||
successCount++
|
||||
} else {
|
||||
decryptedFile.delete()
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
decryptedFile.delete()
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
if (decryptedFile.exists()) {
|
||||
decryptedFile.delete()
|
||||
}
|
||||
failCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decrypting file: ${e.message}")
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
exitSelectionMode()
|
||||
when {
|
||||
successCount > 0 && failCount == 0 -> {
|
||||
Toast.makeText(context, "Decrypted $successCount file(s)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
successCount > 0 && failCount > 0 -> {
|
||||
Toast.makeText(context, "Decrypted $successCount file(s), failed to decrypt $failCount", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
failCount > 0 -> {
|
||||
Toast.makeText(context, "Failed to decrypt $failCount file(s)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val currentFiles = currentFolder.listFiles()?.toList() ?: emptyList()
|
||||
submitList(currentFiles)
|
||||
fileOperationCallback?.get()?.onRefreshNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,14 @@ import devs.org.calculator.databinding.ViewpagerItemsBinding
|
||||
import devs.org.calculator.utils.FileManager
|
||||
import java.io.File
|
||||
import devs.org.calculator.R
|
||||
import devs.org.calculator.database.AppDatabase
|
||||
import devs.org.calculator.database.HiddenFileRepository
|
||||
import devs.org.calculator.utils.SecurityUtils
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import devs.org.calculator.utils.SecurityUtils.getDecryptedPreviewFile
|
||||
import devs.org.calculator.utils.SecurityUtils.getUriForPreviewFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ImagePreviewAdapter(
|
||||
private val context: Context,
|
||||
@@ -26,6 +34,13 @@ class ImagePreviewAdapter(
|
||||
private val differ = AsyncListDiffer(this, FileDiffCallback())
|
||||
private var currentPlayingPosition = -1
|
||||
private var currentViewHolder: ImageViewHolder? = null
|
||||
private val hiddenFileRepository: HiddenFileRepository by lazy {
|
||||
HiddenFileRepository(AppDatabase.getDatabase(context).hiddenFileDao())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ImagePreviewAdapter"
|
||||
}
|
||||
|
||||
var images: List<File>
|
||||
get() = differ.currentList
|
||||
@@ -38,11 +53,10 @@ class ImagePreviewAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
|
||||
val imageUrl = images[position]
|
||||
val fileType = FileManager(context, lifecycleOwner).getFileType(images[position])
|
||||
|
||||
stopAndResetCurrentAudio()
|
||||
|
||||
holder.bind(imageUrl, fileType, position)
|
||||
holder.bind(imageUrl, position)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = images.size
|
||||
@@ -61,20 +75,46 @@ class ImagePreviewAdapter(
|
||||
private var isMediaPlayerPrepared = false
|
||||
private var isPlaying = false
|
||||
private var currentPosition = 0
|
||||
private var tempDecryptedFile: File? = null
|
||||
|
||||
fun bind(file: File, fileType: FileManager.FileType, position: Int) {
|
||||
fun bind(file: File, position: Int) {
|
||||
currentPosition = position
|
||||
|
||||
releaseMediaPlayer()
|
||||
resetAudioUI()
|
||||
cleanupTempFile()
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val hiddenFile = hiddenFileRepository.getHiddenFileByPath(file.absolutePath)
|
||||
val isEncrypted = hiddenFile?.isEncrypted == true
|
||||
val fileType = hiddenFile!!.fileType
|
||||
if (isEncrypted) {
|
||||
|
||||
val tempDecryptedFile = getDecryptedPreviewFile(context, hiddenFile)
|
||||
if (tempDecryptedFile != null && tempDecryptedFile.exists() && tempDecryptedFile.length() > 0) {
|
||||
displayFile(tempDecryptedFile, fileType)
|
||||
} else {
|
||||
showEncryptedError()
|
||||
}
|
||||
} else {
|
||||
displayFile(file, fileType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error binding file: ${e.message}", e)
|
||||
showEncryptedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayFile(file: File, fileType: FileManager.FileType) {
|
||||
val uri = getUriForPreviewFile(context, file)
|
||||
when (fileType) {
|
||||
FileManager.FileType.VIDEO -> {
|
||||
binding.imageView.visibility = View.GONE
|
||||
binding.audioBg.visibility = View.GONE
|
||||
binding.videoView.visibility = View.VISIBLE
|
||||
|
||||
val videoUri = Uri.fromFile(file)
|
||||
val videoUri = uri
|
||||
binding.videoView.setVideoURI(videoUri)
|
||||
binding.videoView.start()
|
||||
|
||||
@@ -99,11 +139,12 @@ class ImagePreviewAdapter(
|
||||
}
|
||||
}
|
||||
FileManager.FileType.IMAGE -> {
|
||||
|
||||
binding.imageView.visibility = View.VISIBLE
|
||||
binding.videoView.visibility = View.GONE
|
||||
binding.audioBg.visibility = View.GONE
|
||||
Glide.with(context)
|
||||
.load(file)
|
||||
.load(uri)
|
||||
.into(binding.imageView)
|
||||
}
|
||||
FileManager.FileType.AUDIO -> {
|
||||
@@ -123,6 +164,27 @@ class ImagePreviewAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEncryptedError() {
|
||||
binding.imageView.visibility = View.VISIBLE
|
||||
binding.videoView.visibility = View.GONE
|
||||
binding.audioBg.visibility = View.GONE
|
||||
binding.imageView.setImageResource(R.drawable.encrypted)
|
||||
}
|
||||
|
||||
private fun cleanupTempFile() {
|
||||
tempDecryptedFile?.let { file ->
|
||||
if (file.exists()) {
|
||||
try {
|
||||
file.delete()
|
||||
Log.d(TAG, "Cleaned up temporary decrypted file: ${file.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cleaning up temporary file: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
tempDecryptedFile = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetAudioUI() {
|
||||
binding.playPause.setImageResource(R.drawable.play)
|
||||
binding.audioSeekBar.value = 0f
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package devs.org.calculator.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(entities = [HiddenFileEntity::class], version = 1)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun hiddenFileDao(): HiddenFileDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"calculator_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package devs.org.calculator.database
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface HiddenFileDao {
|
||||
@Query("SELECT * FROM hidden_files")
|
||||
fun getAllHiddenFiles(): Flow<List<HiddenFileEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertHiddenFile(hiddenFile: HiddenFileEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteHiddenFile(hiddenFile: HiddenFileEntity)
|
||||
|
||||
@Query("SELECT * FROM hidden_files WHERE filePath = :filePath")
|
||||
suspend fun getHiddenFileByPath(filePath: String): HiddenFileEntity?
|
||||
|
||||
@Query("SELECT * FROM hidden_files WHERE fileName = :fileName")
|
||||
suspend fun getHiddenFileByOriginalName(fileName: String): HiddenFileEntity?
|
||||
|
||||
@Query("UPDATE hidden_files SET isEncrypted = :isEncrypted, filePath = :newFilePath, encryptedFileName = :encryptedFileName WHERE filePath = :filePath")
|
||||
suspend fun updateEncryptionStatus(
|
||||
filePath: String,
|
||||
newFilePath: String,
|
||||
encryptedFileName: String?,
|
||||
isEncrypted: Boolean
|
||||
)
|
||||
|
||||
@Update
|
||||
suspend fun updateHiddenFile(hiddenFile: HiddenFileEntity)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package devs.org.calculator.database
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import devs.org.calculator.utils.FileManager
|
||||
|
||||
@Entity(tableName = "hidden_files")
|
||||
data class HiddenFileEntity(
|
||||
@PrimaryKey
|
||||
val filePath: String, //absolute path of the file
|
||||
val fileName: String, // Original filename with extension
|
||||
val encryptedFileName: String, // Encrypted filename
|
||||
val fileType: FileManager.FileType, //type of the file
|
||||
val originalExtension: String, // original file extension
|
||||
val isEncrypted: Boolean, // is the file encrypted or not
|
||||
var dateAdded: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package devs.org.calculator.database
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class HiddenFileRepository(private val hiddenFileDao: HiddenFileDao) {
|
||||
|
||||
fun getAllHiddenFiles(): Flow<List<HiddenFileEntity>> {
|
||||
return hiddenFileDao.getAllHiddenFiles()
|
||||
}
|
||||
|
||||
suspend fun insertHiddenFile(hiddenFile: HiddenFileEntity) {
|
||||
hiddenFileDao.insertHiddenFile(hiddenFile)
|
||||
}
|
||||
|
||||
suspend fun deleteHiddenFile(hiddenFile: HiddenFileEntity) {
|
||||
hiddenFileDao.deleteHiddenFile(hiddenFile)
|
||||
}
|
||||
|
||||
suspend fun getHiddenFileByPath(filePath: String): HiddenFileEntity? {
|
||||
return hiddenFileDao.getHiddenFileByPath(filePath)
|
||||
}
|
||||
|
||||
suspend fun updateEncryptionStatus(filePath: String, newFilePath: String,encryptedFileName: String, isEncrypted: Boolean) {
|
||||
hiddenFileDao.updateEncryptionStatus(filePath,newFilePath, encryptedFileName = encryptedFileName, isEncrypted)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,10 +27,20 @@ import java.io.File
|
||||
import android.Manifest
|
||||
import androidx.core.content.FileProvider
|
||||
import devs.org.calculator.R
|
||||
import devs.org.calculator.database.AppDatabase
|
||||
import devs.org.calculator.database.HiddenFileRepository
|
||||
import devs.org.calculator.utils.PrefsUtil
|
||||
import android.util.Log
|
||||
import devs.org.calculator.database.HiddenFileEntity
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class FileManager(private val context: Context, private val lifecycleOwner: LifecycleOwner) {
|
||||
private lateinit var intentSenderLauncher: ActivityResultLauncher<IntentSenderRequest>
|
||||
val intent = Intent()
|
||||
private val prefs: PrefsUtil by lazy { PrefsUtil(context) }
|
||||
val hiddenFileRepository: HiddenFileRepository by lazy {
|
||||
HiddenFileRepository(AppDatabase.getDatabase(context).hiddenFileDao())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val HIDDEN_DIR = ".CalculatorHide"
|
||||
@@ -38,6 +48,7 @@ class FileManager(private val context: Context, private val lifecycleOwner: Life
|
||||
const val VIDEOS_DIR = "videos"
|
||||
const val AUDIO_DIR = "audio"
|
||||
const val DOCS_DIR = "documents"
|
||||
const val ENCRYPTED_EXTENSION = ".enc"
|
||||
}
|
||||
|
||||
fun getHiddenDirectory(): File {
|
||||
@@ -92,7 +103,7 @@ class FileManager(private val context: Context, private val lifecycleOwner: Life
|
||||
val extension = MimeTypeMap.getSingleton()
|
||||
.getExtensionFromMimeType(mimeType) ?: ""
|
||||
val fileName = "${System.currentTimeMillis()}.${extension}"
|
||||
val targetFile = File(targetDir, fileName)
|
||||
var targetFile = File(targetDir, fileName)
|
||||
|
||||
// Copy file using DocumentFile
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
@@ -106,6 +117,8 @@ class FileManager(private val context: Context, private val lifecycleOwner: Life
|
||||
throw Exception("File copy failed")
|
||||
}
|
||||
|
||||
// Encrypt file if encryption is enabled
|
||||
|
||||
// Media scan the new file to hide it
|
||||
val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
|
||||
mediaScanIntent.data = Uri.fromFile(targetDir)
|
||||
@@ -179,41 +192,39 @@ class FileManager(private val context: Context, private val lifecycleOwner: Life
|
||||
targetFile
|
||||
}
|
||||
|
||||
// Copy file content
|
||||
file.copyTo(finalTargetFile, overwrite = false)
|
||||
|
||||
// Verify copy success
|
||||
if (finalTargetFile.exists() && finalTargetFile.length() > 0) {
|
||||
// Delete original hidden file
|
||||
if (file.delete()) {
|
||||
// Trigger media scan for the new file
|
||||
val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
|
||||
mediaScanIntent.data = Uri.fromFile(finalTargetFile)
|
||||
context.sendBroadcast(mediaScanIntent)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, context.getString(R.string.file_unhidden_successfully), Toast.LENGTH_SHORT).show()
|
||||
onSuccess?.invoke() // Call success callback
|
||||
}
|
||||
// Check if file is encrypted
|
||||
if (file.extension == ENCRYPTED_EXTENSION) {
|
||||
// Decrypt file
|
||||
val decryptedFile = SecurityUtils.changeFileExtension(file, SecurityUtils.getFileExtension(file))
|
||||
if (SecurityUtils.decryptFile(context, file, decryptedFile)) {
|
||||
decryptedFile.copyTo(finalTargetFile, overwrite = false)
|
||||
decryptedFile.delete()
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "File copied but failed to remove from hidden folder", Toast.LENGTH_SHORT).show()
|
||||
onError?.invoke("Failed to remove from hidden folder")
|
||||
}
|
||||
throw Exception("Failed to decrypt file")
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Failed to copy file", Toast.LENGTH_SHORT).show()
|
||||
onError?.invoke("Failed to copy file")
|
||||
}
|
||||
// Copy file content
|
||||
file.copyTo(finalTargetFile, overwrite = false)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Error unhiding file: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
onError?.invoke(e.message ?: "Unknown error")
|
||||
// Verify copy success
|
||||
if (!finalTargetFile.exists() || finalTargetFile.length() == 0L) {
|
||||
throw Exception("File copy failed")
|
||||
}
|
||||
|
||||
// Media scan the new file
|
||||
val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
|
||||
mediaScanIntent.data = Uri.fromFile(finalTargetFile)
|
||||
context.sendBroadcast(mediaScanIntent)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onSuccess?.invoke()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) {
|
||||
onError?.invoke(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ class PrefsUtil(context: Context) {
|
||||
return stored == hashPassword(input)
|
||||
}
|
||||
|
||||
fun getPassword(): String{
|
||||
return prefs.getString("password", "") ?: ""
|
||||
}
|
||||
|
||||
fun saveSecurityQA(question: String, answer: String) {
|
||||
prefs.edit {
|
||||
putString("security_question", question)
|
||||
|
||||
@@ -2,63 +2,252 @@ package devs.org.calculator.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.FileProvider
|
||||
import devs.org.calculator.database.HiddenFileEntity
|
||||
|
||||
class SecurityUtils {
|
||||
companion object {
|
||||
private const val ALGORITHM = "AES"
|
||||
private const val HIDDEN_FOLDER = "Calculator_Data"
|
||||
|
||||
fun validatePassword(input: String, storedHash: String): Boolean {
|
||||
return input.hashCode().toString() == storedHash
|
||||
}
|
||||
object SecurityUtils {
|
||||
private const val ALGORITHM = "AES"
|
||||
private const val TRANSFORMATION = "AES/CBC/PKCS5Padding"
|
||||
private const val KEY_SIZE = 256
|
||||
private const val TAG = "SecurityUtils"
|
||||
|
||||
fun encryptFile(context: Context, sourceUri: Uri, password: String): File {
|
||||
val inputStream = context.contentResolver.openInputStream(sourceUri)
|
||||
val secretKey = generateKey(password)
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
private fun getSecretKey(context: Context): SecretKey {
|
||||
val keyStore = context.getSharedPreferences("keystore", Context.MODE_PRIVATE)
|
||||
val encodedKey = keyStore.getString("secret_key", null)
|
||||
|
||||
val hiddenDir = File(context.getExternalFilesDir(null), HIDDEN_FOLDER)
|
||||
if (!hiddenDir.exists()) hiddenDir.mkdirs()
|
||||
|
||||
val encryptedFile = File(hiddenDir, "${System.currentTimeMillis()}_encrypted")
|
||||
val outputStream = FileOutputStream(encryptedFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
val buffer = ByteArray(1024)
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
val encrypted = cipher.update(buffer, 0, read)
|
||||
outputStream.write(encrypted)
|
||||
}
|
||||
val finalBlock = cipher.doFinal()
|
||||
outputStream.write(finalBlock)
|
||||
return if (encodedKey != null) {
|
||||
try {
|
||||
val decodedKey = android.util.Base64.decode(encodedKey, android.util.Base64.DEFAULT)
|
||||
SecretKeySpec(decodedKey, ALGORITHM)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decoding stored key, generating new key", e)
|
||||
generateAndStoreNewKey(keyStore)
|
||||
}
|
||||
outputStream.close()
|
||||
return encryptedFile
|
||||
}
|
||||
|
||||
fun decryptFile(file: File, password: String): ByteArray {
|
||||
val secretKey = generateKey(password)
|
||||
val cipher = Cipher.getInstance(ALGORITHM)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey)
|
||||
|
||||
val inputStream = FileInputStream(file)
|
||||
val bytes = inputStream.readBytes()
|
||||
inputStream.close()
|
||||
|
||||
return cipher.doFinal(bytes)
|
||||
}
|
||||
|
||||
private fun generateKey(password: String): SecretKey {
|
||||
val keyBytes = password.toByteArray().copyOf(16)
|
||||
return SecretKeySpec(keyBytes, ALGORITHM)
|
||||
} else {
|
||||
Log.d(TAG, "No stored key found, generating new key")
|
||||
generateAndStoreNewKey(keyStore)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateAndStoreNewKey(keyStore: SharedPreferences): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(ALGORITHM)
|
||||
keyGenerator.init(KEY_SIZE, SecureRandom())
|
||||
val key = keyGenerator.generateKey()
|
||||
val encodedKey = android.util.Base64.encodeToString(key.encoded, android.util.Base64.DEFAULT)
|
||||
keyStore.edit().putString("secret_key", encodedKey).apply()
|
||||
return key
|
||||
}
|
||||
|
||||
fun encryptFile(context: Context, inputFile: File, outputFile: File): Boolean {
|
||||
return try {
|
||||
if (!inputFile.exists()) {
|
||||
Log.e(TAG, "Input file does not exist: ${inputFile.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
val secretKey = getSecretKey(context)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||
|
||||
FileInputStream(inputFile).use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
// Write IV at the beginning of the file
|
||||
output.write(iv)
|
||||
CipherOutputStream(output, cipher).use { cipherOutput ->
|
||||
input.copyTo(cipherOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the encrypted file exists and has content
|
||||
if (!outputFile.exists() || outputFile.length() == 0L) {
|
||||
Log.e(TAG, "Encrypted file is empty or does not exist: ${outputFile.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify we can read the IV from the encrypted file
|
||||
FileInputStream(outputFile).use { input ->
|
||||
val iv = ByteArray(16)
|
||||
val bytesRead = input.read(iv)
|
||||
if (bytesRead != 16) {
|
||||
Log.e(TAG, "Failed to verify IV in encrypted file: expected 16 bytes but got $bytesRead")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error encrypting file: ${e.message}", e)
|
||||
// Clean up the output file if it exists
|
||||
if (outputFile.exists()) {
|
||||
outputFile.delete()
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getDecryptedPreviewFile(context: Context, meta: HiddenFileEntity): File? {
|
||||
try {
|
||||
val encryptedFile = File(meta.filePath)
|
||||
if (!encryptedFile.exists()) {
|
||||
Log.e(TAG, "Encrypted file does not exist: ${meta.filePath}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Create a unique temp file name using the original file name
|
||||
val tempDir = File(context.cacheDir, "preview_temp")
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
// Use the original extension from metadata
|
||||
val tempFile = File(tempDir, "preview_${System.currentTimeMillis()}_${meta.fileName}")
|
||||
|
||||
// Clean up any existing temp files
|
||||
tempDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
// Attempt to decrypt the file
|
||||
val success = decryptFile(context, encryptedFile, tempFile)
|
||||
|
||||
if (success && tempFile.exists() && tempFile.length() > 0) {
|
||||
Log.d(TAG, "Successfully created preview file: ${tempFile.absolutePath}")
|
||||
return tempFile
|
||||
} else {
|
||||
Log.e(TAG, "Failed to create preview file or file is empty")
|
||||
if (tempFile.exists()) tempFile.delete()
|
||||
return null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error creating preview file: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getDecryptedFileUri(context: Context, encryptedFile: File): Uri? {
|
||||
return try {
|
||||
// Create temp file in cache dir with same extension
|
||||
val extension = getFileExtension(encryptedFile)
|
||||
val tempFile = File.createTempFile("decrypted_", extension, context.cacheDir)
|
||||
|
||||
if (decryptFile(context, encryptedFile, tempFile)) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
tempFile
|
||||
)
|
||||
uri
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get decrypted file URI: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getUriForPreviewFile(context: Context, file: File): Uri? {
|
||||
return try {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider", // Must match AndroidManifest
|
||||
file
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("PreviewUtils", "Error getting URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun decryptFile(context: Context, inputFile: File, outputFile: File): Boolean {
|
||||
return try {
|
||||
if (!inputFile.exists()) {
|
||||
Log.e(TAG, "Input file does not exist: ${inputFile.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
if (inputFile.length() == 0L) {
|
||||
Log.e(TAG, "Input file is empty: ${inputFile.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
val secretKey = getSecretKey(context)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
|
||||
// First verify we can read the IV
|
||||
FileInputStream(inputFile).use { input ->
|
||||
val iv = ByteArray(16)
|
||||
val bytesRead = input.read(iv)
|
||||
if (bytesRead != 16) {
|
||||
Log.e(TAG, "Failed to read IV: expected 16 bytes but got $bytesRead")
|
||||
return false
|
||||
}
|
||||
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||
|
||||
// Create a new input stream for the actual decryption
|
||||
FileInputStream(inputFile).use { decInput ->
|
||||
// Skip the IV
|
||||
decInput.skip(16)
|
||||
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
CipherInputStream(decInput, cipher).use { cipherInput ->
|
||||
cipherInput.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the decrypted file exists and has content
|
||||
if (!outputFile.exists() || outputFile.length() == 0L) {
|
||||
Log.e(TAG, "Decrypted file is empty or does not exist: ${outputFile.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decrypting file: ${e.message}", e)
|
||||
// Clean up the output file if it exists
|
||||
if (outputFile.exists()) {
|
||||
outputFile.delete()
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileExtension(file: File): String {
|
||||
val name = file.name
|
||||
val lastDotIndex = name.lastIndexOf('.')
|
||||
return if (lastDotIndex > 0) {
|
||||
name.substring(lastDotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun changeFileExtension(file: File, newExtension: String): File {
|
||||
val name = file.name
|
||||
val lastDotIndex = name.lastIndexOf('.')
|
||||
val newName = if (lastDotIndex > 0) {
|
||||
name.substring(0, lastDotIndex) + newExtension
|
||||
} else {
|
||||
name + newExtension
|
||||
}
|
||||
return File(file.parent, newName)
|
||||
}
|
||||
}
|
||||
17
app/src/main/res/drawable/encrypted.xml
Normal file
17
app/src/main/res/drawable/encrypted.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="50dp" android:viewportHeight="73" android:viewportWidth="73" android:width="50dp">
|
||||
|
||||
<path android:fillColor="#00FFFFFF" android:fillType="nonZero" android:pathData="M15,1L58,1A14,14 0,0 1,72 15L72,58A14,14 0,0 1,58 72L15,72A14,14 0,0 1,1 58L1,15A14,14 0,0 1,15 1z" android:strokeColor="#0004673E" android:strokeWidth="2"/>
|
||||
|
||||
<path android:fillColor="#00DD80" android:fillType="nonZero" android:pathData="M54.191,43.754C52.953,47.108 51.081,50.025 48.626,52.423C45.832,55.151 42.173,57.319 37.751,58.866C37.605,58.917 37.454,58.958 37.302,58.989C37.101,59.028 36.896,59.05 36.694,59.053L36.654,59.053C36.438,59.053 36.221,59.031 36.005,58.989C35.853,58.958 35.704,58.917 35.56,58.867C31.132,57.323 27.469,55.156 24.671,52.427C22.215,50.03 20.344,47.115 19.108,43.76C16.86,37.66 16.988,30.941 17.091,25.542L17.093,25.459C17.113,25.013 17.127,24.544 17.134,24.027C17.172,21.488 19.191,19.387 21.73,19.246C27.025,18.95 31.121,17.223 34.621,13.812L34.652,13.784C35.233,13.251 35.965,12.989 36.694,13C37.396,13.009 38.096,13.271 38.657,13.784L38.687,13.812C42.187,17.223 46.283,18.95 51.578,19.246C54.118,19.387 56.137,21.488 56.174,24.027C56.182,24.548 56.195,25.016 56.216,25.459L56.217,25.494C56.319,30.904 56.446,37.636 54.191,43.754Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
<path android:fillColor="#03D078" android:fillType="nonZero" android:pathData="M54.191,43.754C52.953,47.108 51.081,50.025 48.626,52.423C45.832,55.151 42.173,57.319 37.751,58.866C37.605,58.917 37.454,58.958 37.302,58.989C37.101,59.028 36.896,59.05 36.694,59.053L36.694,13C37.396,13.009 38.096,13.271 38.657,13.784L38.687,13.812C42.187,17.223 46.283,18.95 51.578,19.246C54.118,19.387 56.137,21.488 56.174,24.027C56.182,24.548 56.195,25.016 56.216,25.459L56.217,25.494C56.319,30.904 56.446,37.636 54.191,43.754Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
<path android:fillColor="#00FFFFFF" android:fillType="nonZero" android:pathData="M48.131,36.026C48.131,42.341 43.003,47.482 36.694,47.504L36.653,47.504C30.325,47.504 25.176,42.355 25.176,36.026C25.176,29.698 30.325,24.549 36.653,24.549L36.694,24.549C43.003,24.571 48.131,29.712 48.131,36.026Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
<path android:fillColor="#00E1EBF0" android:fillType="nonZero" android:pathData="M48.131,36.026C48.131,42.341 43.003,47.482 36.694,47.504L36.694,24.549C43.003,24.571 48.131,29.712 48.131,36.026Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
<path android:fillColor="#FFFFFF" android:fillType="nonZero" android:pathData="M41.863,34.374L36.694,39.543L35.577,40.66C35.313,40.924 34.967,41.056 34.621,41.056C34.275,41.056 33.929,40.924 33.665,40.66L31.264,38.258C30.736,37.73 30.736,36.875 31.264,36.347C31.791,35.819 32.646,35.819 33.174,36.347L34.621,37.794L39.952,32.463C40.48,31.935 41.336,31.935 41.863,32.463C42.391,32.991 42.391,33.847 41.863,34.374Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
<path android:fillColor="#F8F6F6" android:fillType="nonZero" android:pathData="M41.863,34.374L36.694,39.543L36.694,35.721L39.952,32.463C40.48,31.935 41.336,31.935 41.863,32.463C42.391,32.991 42.391,33.847 41.863,34.374Z" android:strokeColor="#00000000" android:strokeWidth="1"/>
|
||||
|
||||
</vector>
|
||||
@@ -256,6 +256,15 @@
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/restrict_screenshots_in_hidden_section"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/encryptionSwitch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/encrypt_file_when_hiding"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_play_circle"
|
||||
android:layout_gravity="center"/>
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:visibility="gone"
|
||||
android:src="@drawable/encrypted"
|
||||
android:layout_gravity="end"
|
||||
android:id="@+id/encrypted"/>
|
||||
<ImageView
|
||||
android:id="@+id/selected"
|
||||
android:layout_width="20dp"
|
||||
@@ -69,6 +76,7 @@
|
||||
android:src="@drawable/selected"
|
||||
android:background="@drawable/gradient_bg"
|
||||
android:layout_gravity="end"/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<string name="app_settings">App Settings</string>
|
||||
<string name="restrict_screenshots_in_hidden_section">Restrict Screenshots in Hidden Section</string>
|
||||
<string name="security_settings">Security Settings</string>
|
||||
<string name="version">Version 1.3</string>
|
||||
<string name="version">Version 1.4.0</string>
|
||||
<string name="app_details">App Details</string>
|
||||
<string name="setup_password">Setup Password</string>
|
||||
<string name="security_question_for_password_reset">Security Question (For Password Reset)</string>
|
||||
@@ -150,4 +150,13 @@
|
||||
<string name="if_you_turn_on_off_this_option_dynamic_theme_changes_will_be_visible_after_you_reopen_the_app">If you turn on/off this option, dynamic theme changes will be visible after you reopen the app.</string>
|
||||
<string name="attention">Attention!</string>
|
||||
<string name="are_you_sure_to_delete_selected_files_permanently">Are you sure to delete selected files permanently ?</string>
|
||||
<string name="encrypt_file_when_hiding">Encrypt File When Hiding</string>
|
||||
<string name="encrypt_file">Encrypt File</string>
|
||||
<string name="decrypt_file">Decrypt File</string>
|
||||
<string name="encrypt_files">Encrypt Files</string>
|
||||
<string name="decrypt_files">Decrypt Files</string>
|
||||
<string name="encrypt">Encrypt</string>
|
||||
<string name="decrypt">Decrypt</string>
|
||||
<string name="encryption_disclaimer">Warning: This will encrypt the selected files. The original files will be deleted after successful encryption. Make sure to do this. Do you want to continue?</string>
|
||||
<string name="decryption_disclaimer">Warning: This will decrypt the selected files. The encrypted files will be deleted after successful decryption. Make sure to do this. Do you want to continue?</string>
|
||||
</resources>
|
||||
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Allow access to external files -->
|
||||
<external-path name="external_files" path="." />
|
||||
<!-- Allow access to hidden directories -->
|
||||
<external-files-path name="hidden_files" path=".CalculatorHide/" />
|
||||
<cache-path name="cache_files" path="." />
|
||||
</paths>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user