Added - file encryption using room db.

This commit is contained in:
Binondi
2025-06-05 21:22:26 +05:30
parent e90e3c438f
commit eea0f56379
23 changed files with 1136 additions and 483 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
}
}
}

View File

@@ -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 ->

View File

@@ -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()
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()
)

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>