diff --git a/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt b/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt index 793ccef..b8b7a8d 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt @@ -26,6 +26,8 @@ import com.simplified.wsstatussaver.repository.RepositoryImpl import com.simplified.wsstatussaver.repository.StatusesRepository import com.simplified.wsstatussaver.repository.StatusesRepositoryImpl import com.simplified.wsstatussaver.storage.Storage +import com.simplified.wsstatussaver.storage.whatsapp.WaContentStorage +import com.simplified.wsstatussaver.storage.whatsapp.WaSavedContentStorage import io.michaelrocks.libphonenumber.android.PhoneNumberUtil import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel @@ -39,6 +41,10 @@ private val networkModule = module { } private val dataModule = module { + single { + androidContext().contentResolver + } + single { Room.databaseBuilder(androidContext(), StatusDatabase::class.java, "statuses.db") .addMigrations(MIGRATION_1_2) @@ -58,6 +64,12 @@ private val managerModule = module { single { PhoneNumberUtil.createInstance(androidContext()) } + single { + WaContentStorage(androidContext(), get()) + } + single { + WaSavedContentStorage(androidContext(), get()) + } single { Storage(androidContext()) } @@ -69,7 +81,7 @@ private val statusesModule = module { } bind CountryRepository::class single { - StatusesRepositoryImpl(androidContext(), get(), get()) + StatusesRepositoryImpl(androidContext(), get(), get(), get()) } bind StatusesRepository::class single { diff --git a/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt b/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt index cf2e24a..e49e6b3 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt @@ -18,11 +18,13 @@ import android.Manifest.permission.READ_MEDIA_IMAGES import android.Manifest.permission.READ_MEDIA_VIDEO import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.annotation.SuppressLint +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.UriPermission import android.net.Uri import android.os.Build +import android.provider.DocumentsContract import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -50,10 +52,34 @@ fun getApplicablePermissions() = getRequestedPermissions() .flatMap { it.permissions.asIterable() } .toTypedArray() +fun Uri.isTreeUri() = DocumentsContract.isTreeUri(this) + +fun Uri.isWhatsAppDirectory() = WaDirectory.entries.any { it.isThis(this) } + fun Uri.toWhatsAppDirectory() = WaDirectory.entries.firstOrNull { it.isThis(this) } +fun Uri.isCustomSaveDirectory(contentResolver: ContentResolver): Boolean { + if (!isTreeUri() || isWhatsAppDirectory()) + return false + + return contentResolver.allPermissionsGranted(this) +} + fun Context.getReadableDirectories() = contentResolver.persistedUriPermissions.getReadableDirectories() +fun ContentResolver.takePermissions(uri: Uri, flags: Int): Boolean { + val result = runCatching { takePersistableUriPermission(uri, flags) } + if (result.isFailure) { + result.exceptionOrNull()?.printStackTrace() + } + return result.isSuccess +} + +fun ContentResolver.allPermissionsGranted(against: Uri) = + persistedUriPermissions.any { it.allPermissionsGranted(against) } + +fun UriPermission.allPermissionsGranted(against: Uri) = uri == against && isWritePermission && isReadPermission + fun List.getReadableDirectories() = WaDirectory.entries.filter { it.isReadable(this) } fun Context.hasStoragePermissions(): Boolean = doIHavePermissions(*getApplicablePermissions()) diff --git a/app/src/main/java/com/simplified/wsstatussaver/extensions/StatusExt.kt b/app/src/main/java/com/simplified/wsstatussaver/extensions/StatusExt.kt index 0bd3f15..7f4ea71 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/extensions/StatusExt.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/extensions/StatusExt.kt @@ -17,7 +17,6 @@ import android.content.Context import com.simplified.wsstatussaver.R import com.simplified.wsstatussaver.model.Status import com.simplified.wsstatussaver.model.StatusType -import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -51,6 +50,4 @@ fun getNewSaveName(type: StatusType? = null, timeMillis: Long, delta: Int): Stri fun Status.getState(context: Context): String = if (isSaved) context.getString(R.string.status_saved) else context.getString(R.string.status_unsaved) -fun StatusType.acceptFileName(fileName: String): Boolean = !fileName.startsWith(".") && fileName.endsWith(this.format) - -fun File.getStatusType() = StatusType.entries.firstOrNull { it.acceptFileName(name) } \ No newline at end of file +fun StatusType.acceptFileName(fileName: String): Boolean = !fileName.startsWith(".") && fileName.endsWith(this.format) \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/SaveLocation.kt b/app/src/main/java/com/simplified/wsstatussaver/model/SaveLocation.kt index 3d1fef1..5c2d262 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/SaveLocation.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/SaveLocation.kt @@ -17,5 +17,10 @@ import android.os.Environment enum class SaveLocation(internal val videoDir: String, internal val imageDir: String) { DCIM(Environment.DIRECTORY_DCIM, Environment.DIRECTORY_DCIM), - ByFileType(Environment.DIRECTORY_MOVIES, Environment.DIRECTORY_PICTURES); + ByFileType(Environment.DIRECTORY_MOVIES, Environment.DIRECTORY_PICTURES), + Custom(Environment.DIRECTORY_DCIM, Environment.DIRECTORY_DCIM); + + companion object { + fun getWithoutCustom() = SaveLocation.entries.filterNot { it == Custom } + } } \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/StatusSaveType.kt b/app/src/main/java/com/simplified/wsstatussaver/model/StatusSaveType.kt index 7806a44..be77366 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/StatusSaveType.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/StatusSaveType.kt @@ -21,11 +21,11 @@ import android.provider.MediaStore * * @author Christians Martínez Alvarado (mardous) */ -internal enum class StatusSaveType( - internal val dirName: String, - internal val fileMimeType: String, - internal val contentUri: Uri, - internal val dirTypeProvider: (SaveLocation) -> String +enum class StatusSaveType( + val dirName: String, + val fileMimeType: String, + val contentUri: Uri, + val dirTypeProvider: (SaveLocation) -> String ) { IMAGE_SAVE( dirName = "Saved Image Statuses", @@ -39,4 +39,7 @@ internal enum class StatusSaveType( contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI, dirTypeProvider = { it.videoDir } ); + + val customDirectoryId: String + get() = "custom.${name.lowercase()}" } \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt b/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt index e117100..92b7ab2 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt @@ -13,26 +13,16 @@ */ package com.simplified.wsstatussaver.model -import android.content.ContentResolver -import android.database.Cursor import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore.MediaColumns -import androidx.annotation.RequiresApi import androidx.annotation.StringRes import com.simplified.wsstatussaver.R -import com.simplified.wsstatussaver.extensions.acceptFileName import com.simplified.wsstatussaver.extensions.getNewSaveName -import java.io.File /** * @author Christians Martínez Alvarado (mardous) */ enum class StatusType( - @param:StringRes val nameRes: Int, - val format: String, - private val saveType: StatusSaveType + @param:StringRes val nameRes: Int, val format: String, val saveType: StatusSaveType ) { IMAGE(R.string.type_images, ".jpg", StatusSaveType.IMAGE_SAVE), VIDEO(R.string.type_videos, ".mp4", StatusSaveType.VIDEO_SAVE); @@ -42,31 +32,4 @@ enum class StatusType( val contentUri: Uri get() = saveType.contentUri val mimeType: String get() = saveType.fileMimeType - - @RequiresApi(Build.VERSION_CODES.Q) - fun getRelativePath(location: SaveLocation): String = - String.format("%s/%s", saveType.dirTypeProvider(location), saveType.dirName) - - fun getSavesDirectory(location: SaveLocation): File = - File(Environment.getExternalStoragePublicDirectory(saveType.dirTypeProvider(location)), saveType.dirName) - - fun getSavedContentFiles(location: SaveLocation): Array { - val directory = getSavesDirectory(location) - return directory.listFiles { _, name -> acceptFileName(name) } ?: emptyArray() - } - - @RequiresApi(Build.VERSION_CODES.Q) - fun getSavedMedia(contentResolver: ContentResolver): Cursor? { - val projection = arrayOf( - MediaColumns._ID, - MediaColumns.DISPLAY_NAME, - MediaColumns.DATE_MODIFIED, - MediaColumns.SIZE, - MediaColumns.RELATIVE_PATH - ) - val entries = SaveLocation.entries - val selection = entries.joinToString(" OR ") { "${MediaColumns.RELATIVE_PATH} LIKE ?" } - val arguments = entries.map { "%${getRelativePath(it)}%" }.toTypedArray() - return contentResolver.query(contentUri, projection, selection, arguments, null) - } } \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt b/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt index 314cdda..17034f8 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt @@ -25,6 +25,9 @@ import com.simplified.wsstatussaver.storage.Storage typealias SegmentResolver = (WaClient) -> List data class WaDirectoryUri(val client: WaClient?, val treeUri: Uri, private val documentId: String) { + val documentUri: Uri + get() = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) + val childDocumentsUri: Uri get() = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId) } diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt b/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt new file mode 100644 index 0000000..8936f1c --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 Christians Martínez Alvarado + * + * Licensed under the GNU General Public License v3 + * + * This is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation either version 3 of the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + */ +package com.simplified.wsstatussaver.model + +import android.net.Uri +import java.util.concurrent.TimeUnit + +class WaFile( + val id: String?, + val owner: WaClient?, + val path: String?, + val name: String, + val lastModified: Long, + val size: Long, + val uri: Uri +) { + fun toStatus(type: StatusType, isSaved: Boolean) = + Status(type, name, uri, lastModified, size, owner?.packageName, isSaved) + + fun toSavedStatus(type: StatusType) = + SavedStatus(type, name, uri, lastModified, size, path) + + fun isOlderThan(timeUnit: TimeUnit, duration: Long): Boolean { + return (System.currentTimeMillis() - lastModified) >= timeUnit.toMillis(duration) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt b/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt index c747807..f66f0eb 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt @@ -14,8 +14,12 @@ package com.simplified.wsstatussaver.preferences import android.app.Dialog +import android.net.Uri import android.os.Bundle import android.view.View +import android.widget.CompoundButton +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.simplified.wsstatussaver.R @@ -23,23 +27,44 @@ import com.simplified.wsstatussaver.databinding.DialogSaveLocationBinding import com.simplified.wsstatussaver.extensions.check import com.simplified.wsstatussaver.extensions.preferences import com.simplified.wsstatussaver.extensions.saveLocation +import com.simplified.wsstatussaver.extensions.showToast import com.simplified.wsstatussaver.model.SaveLocation +import com.simplified.wsstatussaver.model.StatusType +import com.simplified.wsstatussaver.storage.whatsapp.WaSavedContentStorage +import org.koin.android.ext.android.inject /** * @author Christians M. A. (mardous) */ class SaveLocationPreferenceDialog : DialogFragment(), View.OnClickListener { + private val waSavedContentStorage: WaSavedContentStorage by inject() + private var _binding: DialogSaveLocationBinding? = null private val binding get() = _binding!! private var selectedLocation: SaveLocation? = null + private var viewMapping = listOf( + ViewIdToSaveLocation(R.id.dcim_option, R.id.dcim_radio, SaveLocation.DCIM), + ViewIdToSaveLocation(R.id.file_type_option, R.id.file_type_radio, SaveLocation.ByFileType), + ViewIdToSaveLocation(R.id.custom_location_option, R.id.custom_location_radio, SaveLocation.Custom) + ) + + private lateinit var imagesDirectorySelector: ActivityResultLauncher + private lateinit var videosDirectorySelector: ActivityResultLauncher + + private class ViewIdToSaveLocation(val parentId: Int, val radioId: Int, val location: SaveLocation) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { _binding = DialogSaveLocationBinding.inflate(layoutInflater) binding.dcimOption.setOnClickListener(this) binding.fileTypeOption.setOnClickListener(this) + binding.customLocationOption.setOnClickListener(this) + binding.imagesLocation.setOnClickListener(this) + binding.videosLocation.setOnClickListener(this) setSaveLocation(preferences().saveLocation) + updateCustomDirectoryNames() + registerForActivityResult() return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.save_location_title) .setView(binding.root) @@ -54,23 +79,50 @@ class SaveLocationPreferenceDialog : DialogFragment(), View.OnClickListener { private fun setSaveLocation(location: SaveLocation) { selectedLocation = location - when (location) { - SaveLocation.DCIM -> { - binding.dcimRadio.check(true) - binding.fileTypeRadio.check(false) + for (entry in viewMapping) { + val view = binding.root.findViewById(entry.radioId) + if (view is CompoundButton) { + view.check(entry.location == selectedLocation) } + } + } - SaveLocation.ByFileType -> { - binding.dcimRadio.check(false) - binding.fileTypeRadio.check(true) - } + private fun setCustomDirectoryLocation(type: StatusType, uri: Uri?) { + if (uri == null) + return + + val isSuccess = waSavedContentStorage.setCustomDirectory(type, uri) + if (isSuccess) { + showToast(R.string.custom_save_directory_set) + updateCustomDirectoryNames() + } else { + showToast(R.string.unable_to_set_custom_save_directory) } } override fun onClick(view: View) { - when (view) { - binding.dcimOption -> setSaveLocation(SaveLocation.DCIM) - binding.fileTypeOption -> setSaveLocation(SaveLocation.ByFileType) + val location = viewMapping.firstOrNull { it.parentId == view.id } + if (location != null) { + setSaveLocation(location.location) + } else { + when (view) { + binding.imagesLocation -> imagesDirectorySelector.launch(null) + binding.videosLocation -> videosDirectorySelector.launch(null) + } + } + } + + private fun updateCustomDirectoryNames() { + binding.imagesLocation.text = waSavedContentStorage.getCustomDirectoryName(StatusType.IMAGE) + binding.videosLocation.text = waSavedContentStorage.getCustomDirectoryName(StatusType.VIDEO) + } + + private fun registerForActivityResult() { + imagesDirectorySelector = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + setCustomDirectoryLocation(StatusType.IMAGE, uri) + } + videosDirectorySelector = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + setCustomDirectoryLocation(StatusType.VIDEO, uri) } } diff --git a/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt b/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt index a56c535..576acf2 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt @@ -19,50 +19,31 @@ import android.content.Context import android.database.Cursor import android.media.MediaScannerConnection import android.net.Uri -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.DocumentsContract.Document import android.provider.MediaStore.MediaColumns -import android.util.Log -import androidx.core.content.contentValuesOf import androidx.lifecycle.LiveData import com.simplified.wsstatussaver.database.StatusDao import com.simplified.wsstatussaver.database.StatusEntity -import com.simplified.wsstatussaver.database.toSavedStatus import com.simplified.wsstatussaver.database.toStatusEntity import com.simplified.wsstatussaver.extensions.IsSAFRequired import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired import com.simplified.wsstatussaver.extensions.acceptFileName -import com.simplified.wsstatussaver.extensions.canonicalOrAbsolutePath import com.simplified.wsstatussaver.extensions.getAllInstalledClients import com.simplified.wsstatussaver.extensions.getPreferred -import com.simplified.wsstatussaver.extensions.getReadableDirectories -import com.simplified.wsstatussaver.extensions.getStatusType -import com.simplified.wsstatussaver.extensions.getUri -import com.simplified.wsstatussaver.extensions.hasElapsedTwentyFourHours import com.simplified.wsstatussaver.extensions.hasStoragePermissions import com.simplified.wsstatussaver.extensions.isExcludeSavedStatuses -import com.simplified.wsstatussaver.extensions.isOldFile import com.simplified.wsstatussaver.extensions.preferences -import com.simplified.wsstatussaver.extensions.saveLocation -import com.simplified.wsstatussaver.model.SaveLocation import com.simplified.wsstatussaver.model.SavedStatus import com.simplified.wsstatussaver.model.ShareData import com.simplified.wsstatussaver.model.Status import com.simplified.wsstatussaver.model.StatusQueryResult import com.simplified.wsstatussaver.model.StatusQueryResult.ResultCode import com.simplified.wsstatussaver.model.StatusType -import com.simplified.wsstatussaver.model.WaClient -import com.simplified.wsstatussaver.model.WaDirectory -import com.simplified.wsstatussaver.model.WaDirectoryUri -import com.simplified.wsstatussaver.storage.Storage -import java.io.File -import java.io.IOException -import java.io.InputStream +import com.simplified.wsstatussaver.storage.whatsapp.WaContentStorage +import com.simplified.wsstatussaver.storage.whatsapp.WaSavedContentStorage +import java.util.concurrent.TimeUnit +import kotlin.collections.filter interface StatusesRepository { - suspend fun statusDirectories(clients: List): Set - suspend fun statusDirectoriesAsFiles(client: WaClient): Set fun statusIsSaved(status: Status): LiveData suspend fun statuses(type: StatusType): StatusQueryResult suspend fun savedStatuses(): StatusQueryResult @@ -81,60 +62,12 @@ interface StatusesRepository { class StatusesRepositoryImpl( private val context: Context, private val statusDao: StatusDao, - private val storage: Storage + private val waContentStorage: WaContentStorage, + private val waSavedContentStorage: WaSavedContentStorage ) : StatusesRepository { private val contentResolver: ContentResolver = context.contentResolver private val preferences = context.preferences() - private val statusSaveLocation: SaveLocation - get() = preferences.saveLocation - private val statusesLocationPath: String - get() { - val statusesLocation = storage.getStatusesLocation() - return statusesLocation?.path ?: storage.externalStoragePath - } - - override suspend fun statusDirectories(clients: List): Set { - val directories = mutableSetOf() - val persistedPermissions = contentResolver.persistedUriPermissions - val readableDirectories = persistedPermissions.getReadableDirectories() - if (readableDirectories.isEmpty()) { - return directories - } - for (perm in persistedPermissions) { - if (!DocumentsContract.isTreeUri(perm.uri)) continue - val matchingDir = readableDirectories.firstOrNull { it.isThis(perm.uri) } - if (matchingDir != null) { - directories.addAll(matchingDir.getStatusesDirectories(context, clients, perm.uri)) - } - } - return directories - } - - override suspend fun statusDirectoriesAsFiles(client: WaClient): Set { - val directories = mutableSetOf() - val paths = WaDirectory.entries.filterNot { it.isLegacy }.mapNotNull { dir -> - if (dir.supportsClient(client)) { - val additionalSegments = dir.additionalSegments(client) - if (additionalSegments.isNotEmpty()) - "${dir.path}/${additionalSegments.joinToString("/")}" - else dir.path - } else { - null - } - } - for (path in paths) { - File(statusesLocationPath, "${path}/accounts") - .takeIf { it.isDirectory }?.let { baseDirectory -> - baseDirectory.list { file, _ -> file.isDirectory }?.forEach { accountName -> - directories.add(File(baseDirectory, "$accountName/Media/.Statuses")) - } - } - - directories.add(File(statusesLocationPath, "${path}/Media/.Statuses")) - } - return directories - } override fun statusIsSaved(status: Status): LiveData = statusDao.statusSavedObservable(status.fileUri, status.name) @@ -147,49 +80,28 @@ class StatusesRepositoryImpl( return StatusQueryResult(ResultCode.NotInstalled) } if (IsSAFRequired) { - val statusesDirectories = statusDirectories(installedClients) + val statusesDirectories = waContentStorage.statusDirectories(installedClients) if (statusesDirectories.isEmpty()) { return StatusQueryResult(ResultCode.PermissionError) } - val documentSelection = arrayOf( - Document.COLUMN_DOCUMENT_ID, //0 - Document.COLUMN_DISPLAY_NAME, //1 - Document.COLUMN_LAST_MODIFIED, //2 - Document.COLUMN_SIZE //3 - ) - for (directory in statusesDirectories) { - contentResolver.query(directory.childDocumentsUri, documentSelection, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) do { - val id = cursor.getString(0) - val fileName = cursor.getString(1) - val lastModified = cursor.getLong(2) - val size = cursor.getLong(3) - val uri = DocumentsContract.buildDocumentUriUsingTree(directory.treeUri, id) - if (type.acceptFileName(fileName)) { - val isOld = lastModified.hasElapsedTwentyFourHours() - val isSaved = statusDao.statusSaved(uri, fileName) - if (isOld || (isSaved && isExcludeSaved)) - continue - - statusList.add(Status(type, fileName, uri, lastModified, size, directory.client?.packageName, isSaved)) - } - } while (cursor.moveToNext()) + waContentStorage.resolveFiles(statusesDirectories) { file -> + if (type.acceptFileName(file.name)) { + val isOld = file.isOlderThan(TimeUnit.HOURS, 24) + val isSaved = statusDao.statusSaved(file.uri, file.name) + if (!isOld && (!isSaved || !isExcludeSaved)) { + statusList.add(file.toStatus(type, isSaved)) + } } } } else { if (context.hasStoragePermissions()) { for (client in installedClients) { - for (directory in statusDirectoriesAsFiles(client)) { - if (!directory.isDirectory) continue - val statuses = directory.listFiles { _, name -> type.acceptFileName(name) } - if (!statuses.isNullOrEmpty()) for (file in statuses) { - val fileUri = file.getUri() - val fileName = file.name - val isSaved = statusDao.statusSaved(fileUri, file.name) - if (fileName.isNullOrEmpty() || file.isOldFile() || (isSaved && isExcludeSaved)) - continue - - statusList.add(Status(type, fileName, fileUri, file.lastModified(), file.length(), client.packageName, isSaved)) + val directories = waContentStorage.statusDirectoriesAsFiles(client) + waContentStorage.resolveFiles(directories, type, client) { file -> + val isOld = file.isOlderThan(TimeUnit.HOURS, 24) + val isSaved = statusDao.statusSaved(file.uri, file.name) + if (!isOld && (!isSaved || !isExcludeSaved)) { + statusList.add(file.toStatus(type, isSaved)) } } } @@ -206,26 +118,26 @@ class StatusesRepositoryImpl( return StatusQueryResult(ResultCode.PermissionError) } val statuses = arrayListOf() - if (IsScopedStorageRequired) { - for (type in StatusType.entries) { - type.getSavedMedia(contentResolver).use { cursor -> - if (cursor != null && cursor.moveToFirst()) do { - statuses.add(cursor.getSavedStatus(type)) - } while (cursor.moveToNext()) + for (type in StatusType.entries) { + val customSaveDir = waSavedContentStorage.getCustomSaveDirectory(type) + if (customSaveDir != null) { + waContentStorage.resolveFiles(setOf(customSaveDir)) { + statuses.add(it.toSavedStatus(type)) } - } - } else { - val files = StatusType.entries.flatMap { type -> - SaveLocation.entries.flatMap { location -> - type.getSavedContentFiles(location).toList() + } else { + if (IsScopedStorageRequired) { + waSavedContentStorage.getSavedMedia(type).use { cursor -> + if (cursor != null && cursor.moveToFirst()) do { + statuses.add(cursor.getSavedStatus(type)) + } while (cursor.moveToNext()) + } + } else { + val directories = waSavedContentStorage.getSaveDirectories(type) + waContentStorage.resolveFiles(directories, type, null) { + statuses.add(it.toSavedStatus(type)) + } } } - if (files.isNotEmpty()) for (file in files) { - val type = file.getStatusType() ?: continue - statuses.add( - SavedStatus(type, file.name, file.getUri(), file.lastModified(), file.length(), file.absolutePath) - ) - } } if (statuses.isEmpty()) { return StatusQueryResult(ResultCode.NoSavedStatuses) @@ -238,18 +150,22 @@ class StatusesRepositoryImpl( return StatusQueryResult(ResultCode.PermissionError) } val statuses = arrayListOf() - if (IsScopedStorageRequired) { - type.getSavedMedia(contentResolver).use { cursor -> - if (cursor != null && cursor.moveToFirst()) do { - statuses.add(cursor.getSavedStatus(type)) - } while (cursor.moveToNext()) + val customSaveDir = waSavedContentStorage.getCustomSaveDirectory(type) + if (customSaveDir != null) { + waContentStorage.resolveFiles(setOf(customSaveDir)) { + statuses.add(it.toSavedStatus(type)) } } else { - val files = SaveLocation.entries.flatMap { - type.getSavedContentFiles(it).toList() - } - if (files.isNotEmpty()) for (file in files) { - statuses.add(SavedStatus(type, file.name, file.getUri(), file.lastModified(), file.length(), file.absolutePath)) + if (IsScopedStorageRequired) { + waSavedContentStorage.getSavedMedia(type).use { cursor -> + if (cursor != null && cursor.moveToFirst()) do { + statuses.add(cursor.getSavedStatus(type)) + } while (cursor.moveToNext()) + } + } else { + waContentStorage.resolveFiles(waSavedContentStorage.getSaveDirectories(type), type, null) { + statuses.add(it.toSavedStatus(type)) + } } } if (statuses.isEmpty()) { @@ -382,8 +298,8 @@ class StatusesRepositoryImpl( private fun createSavedStatus(status: StatusEntity, notify: Boolean): SavedStatus? { val result = runCatching { contentResolver.openInputStream(status.origin)?.use { stream -> - saveStatus(status, stream, notify).also { saveUri -> - if (saveUri != null) { + waSavedContentStorage.toSavedStatus(status, stream, notify).also { savedStatus -> + if (savedStatus != null) { statusDao.saveStatus(status) } } @@ -392,55 +308,6 @@ class StatusesRepositoryImpl( return result.getOrNull() } - @Throws(IOException::class) - private fun saveStatus(status: StatusEntity, inputStream: InputStream, notify: Boolean): SavedStatus? { - if (IsScopedStorageRequired) { - val contentUri = status.type.contentUri - val contentValues = contentValuesOf( - MediaColumns.DISPLAY_NAME to status.saveName, - MediaColumns.RELATIVE_PATH to status.type.getRelativePath(statusSaveLocation), - MediaColumns.MIME_TYPE to status.type.mimeType - ) - var uri: Uri? = null - return try { - with(contentResolver) { - uri = insert(contentUri, contentValues) - if (uri != null) { - openOutputStream(uri)?.use { outputStream -> - inputStream.copyTo(outputStream, SAVE_BUFFER_SIZE) - } - if (notify) { - notifyChange(contentUri, null) - } - } - uri?.let { status.toSavedStatus(it, null) } - } - } catch (e: IOException) { - Log.e("StatusRepository", "Couldn't write content at $uri", e) - if (uri != null) { - contentResolver.delete(uri, null, null) - } - null - } - } - if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) { - val destDirectory = status.type.getSavesDirectory(statusSaveLocation) - if (destDirectory.isDirectory || destDirectory.mkdirs()) { - val statusSaveFile = File(destDirectory, status.saveName) - if (!statusSaveFile.exists() && statusSaveFile.createNewFile()) { - statusSaveFile.outputStream().use { os -> - inputStream.copyTo(os, SAVE_BUFFER_SIZE) - } - return status.toSavedStatus( - uri = statusSaveFile.getUri(), - path = statusSaveFile.canonicalOrAbsolutePath() - ) - } - } - } - return null - } - private fun scanSavedStatus(statuses: List) { if (!IsScopedStorageRequired) { val files = statuses.filter { status -> status.hasFile() } @@ -451,8 +318,4 @@ class StatusesRepositoryImpl( } } } - - companion object { - private const val SAVE_BUFFER_SIZE = 2048 - } } \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt new file mode 100644 index 0000000..a996747 --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 Christians Martínez Alvarado + * + * Licensed under the GNU General Public License v3 + * + * This is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation either version 3 of the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + */ +package com.simplified.wsstatussaver.storage.whatsapp + +import android.content.ContentResolver +import android.content.Context +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import androidx.core.net.toUri +import com.simplified.wsstatussaver.extensions.acceptFileName +import com.simplified.wsstatussaver.extensions.getReadableDirectories +import com.simplified.wsstatussaver.extensions.isTreeUri +import com.simplified.wsstatussaver.model.StatusType +import com.simplified.wsstatussaver.model.WaClient +import com.simplified.wsstatussaver.model.WaDirectory +import com.simplified.wsstatussaver.model.WaDirectoryUri +import com.simplified.wsstatussaver.model.WaFile +import com.simplified.wsstatussaver.storage.Storage +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +class WaContentStorage( + private val context: Context, + private val contentResolver: ContentResolver +) : KoinComponent { + + private val storage: Storage by inject() + private val statusesLocationPath: String + get() { + val statusesLocation = storage.getStatusesLocation() + return statusesLocation?.path ?: storage.externalStoragePath + } + + fun statusDirectories(clients: List): Set { + val directories = mutableSetOf() + val persistedPermissions = contentResolver.persistedUriPermissions + val readableDirectories = persistedPermissions.getReadableDirectories() + if (readableDirectories.isEmpty()) { + return directories + } + for (perm in persistedPermissions) { + if (!perm.uri.isTreeUri()) continue + val matchingDir = readableDirectories.firstOrNull { it.isThis(perm.uri) } + if (matchingDir != null) { + directories.addAll(matchingDir.getStatusesDirectories(context, clients, perm.uri)) + } + } + return directories + } + + fun statusDirectoriesAsFiles(client: WaClient): Set { + val directories = mutableSetOf() + val paths = WaDirectory.entries.filterNot { it.isLegacy }.mapNotNull { dir -> + if (dir.supportsClient(client)) { + val additionalSegments = dir.additionalSegments(client) + if (additionalSegments.isNotEmpty()) + "${dir.path}/${additionalSegments.joinToString("/")}" + else dir.path + } else { + null + } + } + for (path in paths) { + File(statusesLocationPath, "${path}/accounts") + .takeIf { it.isDirectory }?.let { baseDirectory -> + baseDirectory.list { file, _ -> file.isDirectory }?.forEach { accountName -> + directories.add(File(baseDirectory, "$accountName/Media/.Statuses")) + } + } + + directories.add(File(statusesLocationPath, "${path}/Media/.Statuses")) + } + return directories + } + + fun resolveFiles( + directories: Set, + fileConsumer: (WaFile) -> Unit + ) { + val documentSelection = arrayOf( + Document.COLUMN_DOCUMENT_ID, //0 + Document.COLUMN_DISPLAY_NAME, //1 + Document.COLUMN_LAST_MODIFIED, //2 + Document.COLUMN_SIZE //3 + ) + for (directory in directories) { + contentResolver.query(directory.childDocumentsUri, documentSelection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) do { + val id = cursor.getString(0) + val fileName = cursor.getString(1) + val lastModified = cursor.getLong(2) + val size = cursor.getLong(3) + val uri = DocumentsContract.buildDocumentUriUsingTree(directory.treeUri, id) + fileConsumer(WaFile(id, directory.client, null, fileName, lastModified, size, uri)) + } while (cursor.moveToNext()) + } + } + } + + fun resolveFiles(directories: Set, type: StatusType, client: WaClient?, fileConsumer: (WaFile) -> Unit) { + for (directory in directories) { + if (!directory.isDirectory) continue + val files = directory.listFiles { _, name -> type.acceptFileName(name) } + if (!files.isNullOrEmpty()) for (file in files) { + fileConsumer(WaFile(null, client, file.absolutePath, file.name, file.lastModified(), file.length(), file.toUri())) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt new file mode 100644 index 0000000..edc7e5f --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2025 Christians Martínez Alvarado + * + * Licensed under the GNU General Public License v3 + * + * This is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation either version 3 of the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + */ +package com.simplified.wsstatussaver.storage.whatsapp + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.DocumentsContract.getTreeDocumentId +import android.provider.MediaStore.MediaColumns +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.contentValuesOf +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.simplified.wsstatussaver.database.StatusEntity +import com.simplified.wsstatussaver.database.toSavedStatus +import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired +import com.simplified.wsstatussaver.extensions.allPermissionsGranted +import com.simplified.wsstatussaver.extensions.canonicalOrAbsolutePath +import com.simplified.wsstatussaver.extensions.getUri +import com.simplified.wsstatussaver.extensions.isCustomSaveDirectory +import com.simplified.wsstatussaver.extensions.isTreeUri +import com.simplified.wsstatussaver.extensions.isWhatsAppDirectory +import com.simplified.wsstatussaver.extensions.preferences +import com.simplified.wsstatussaver.extensions.saveLocation +import com.simplified.wsstatussaver.extensions.takePermissions +import com.simplified.wsstatussaver.model.SaveLocation +import com.simplified.wsstatussaver.model.SavedStatus +import com.simplified.wsstatussaver.model.StatusType +import com.simplified.wsstatussaver.model.WaDirectoryUri +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream + +class WaSavedContentStorage(private val context: Context, private val contentResolver: ContentResolver) { + + private val preferences = context.preferences() + private val currentSaveLocation: SaveLocation + get() = preferences.saveLocation + + fun getCustomDirectoryName(type: StatusType): String { + return getCustomSaveDirectory(type, SaveLocation.Custom)?.let { + DocumentFile.fromTreeUri(context, it.treeUri)?.name + } ?: type.saveType.dirName + } + + fun setCustomDirectory(type: StatusType, selectedUri: Uri): Boolean { + if (!selectedUri.isTreeUri() || selectedUri.isWhatsAppDirectory()) return false + + val key = type.saveType.customDirectoryId + val currentValue = preferences.getString(key, null) + + val currentUri = currentValue?.toUri()?.takeIf { it.isTreeUri() } + if (currentUri != null && selectedUri == currentUri) { + if (!contentResolver.allPermissionsGranted(selectedUri)) { + return contentResolver.takePermissions(selectedUri, ACCESS_FLAGS) + } + return false + } + + currentUri?.let { + contentResolver.releasePersistableUriPermission(it, ACCESS_FLAGS) + } ?: preferences.edit { remove(key) } + + if (contentResolver.allPermissionsGranted(selectedUri) || contentResolver.takePermissions(selectedUri, ACCESS_FLAGS)) { + preferences.edit { putString(key, selectedUri.toString()) } + return true + } + + return false + } + + fun getCustomSaveDirectory(type: StatusType, saveLocation: SaveLocation = currentSaveLocation): WaDirectoryUri? { + if (saveLocation == SaveLocation.Custom) { + val savedValue = preferences.getString(type.saveType.customDirectoryId, null) + if (!savedValue.isNullOrBlank()) { + val treeUri = savedValue.toUri() + if (treeUri.isCustomSaveDirectory(contentResolver)) { + return WaDirectoryUri(null, treeUri, getTreeDocumentId(treeUri)) + } else { + preferences.edit { remove(type.saveType.customDirectoryId) } + } + } + } + return null + } + + fun getSaveDirectory(type: StatusType, location: SaveLocation = currentSaveLocation): File { + val publicPath = Environment.getExternalStoragePublicDirectory( + type.saveType.dirTypeProvider(location) + ) + return File(publicPath, type.saveType.dirName) + } + + fun getSaveDirectories(type: StatusType): Set { + val directories = SaveLocation.getWithoutCustom() + .mapTo(mutableSetOf()) { getSaveDirectory(type, it) } + return directories + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun getRelativePath(type: StatusType, location: SaveLocation = currentSaveLocation): String = + String.format("%s/%s", type.saveType.dirTypeProvider(location), type.saveType.dirName) + + @RequiresApi(Build.VERSION_CODES.Q) + fun getSavedMedia(statusType: StatusType): Cursor? { + val projection = arrayOf( + MediaColumns._ID, + MediaColumns.DISPLAY_NAME, + MediaColumns.DATE_MODIFIED, + MediaColumns.SIZE, + MediaColumns.RELATIVE_PATH + ) + val entries = SaveLocation.getWithoutCustom() + val selection = entries.joinToString(" OR ") { "${MediaColumns.RELATIVE_PATH} LIKE ?" } + val arguments = entries.map { "%${getRelativePath(statusType, it)}%" }.toTypedArray() + return contentResolver.query(statusType.contentUri, projection, selection, arguments, null) + } + + fun toSavedStatus(status: StatusEntity, inputStream: InputStream, notify: Boolean): SavedStatus? { + val customSaveDirectory = getCustomSaveDirectory(status.type) + if (customSaveDirectory != null) { + return toCustomDirectory(status, inputStream, customSaveDirectory) + } + if (IsScopedStorageRequired) { + return toMediaStore(status, inputStream, notify) + } + return toFileLocation(status, inputStream) + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun toMediaStore( + status: StatusEntity, + inputStream: InputStream, + notify: Boolean + ): SavedStatus? { + val contentUri = status.type.contentUri + val contentValues = contentValuesOf( + MediaColumns.DISPLAY_NAME to status.saveName, + MediaColumns.RELATIVE_PATH to getRelativePath(status.type), + MediaColumns.MIME_TYPE to status.type.mimeType + ) + var uri: Uri? = null + return try { + with(contentResolver) { + uri = insert(contentUri, contentValues) + if (uri != null) { + openOutputStream(uri)?.use { outputStream -> + inputStream.copyTo(outputStream, + SAVE_BUFFER_SIZE + ) + } + if (notify) { + notifyChange(contentUri, null) + } + } + uri?.let { status.toSavedStatus(it, null) } + } + } catch (e: IOException) { + Log.e("StatusRepository", "Couldn't write content at $uri", e) + if (uri != null) { + contentResolver.delete(uri, null, null) + } + null + } + } + + private fun toFileLocation(status: StatusEntity, inputStream: InputStream): SavedStatus? { + if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) { + val destDirectory = getSaveDirectory(status.type) + if (destDirectory.isDirectory || destDirectory.mkdirs()) { + val statusSaveFile = File(destDirectory, status.saveName) + if (!statusSaveFile.exists() && statusSaveFile.createNewFile()) { + statusSaveFile.outputStream().use { os -> + inputStream.copyTo(os, SAVE_BUFFER_SIZE) + } + return status.toSavedStatus( + uri = statusSaveFile.getUri(), + path = statusSaveFile.canonicalOrAbsolutePath() + ) + } + } + } + return null + } + + private fun toCustomDirectory( + status: StatusEntity, + inputStream: InputStream, + directory: WaDirectoryUri + ): SavedStatus? { + val directory = DocumentFile.fromTreeUri(context, directory.treeUri) ?: return null + val newFile = directory.createFile(status.type.mimeType, status.saveName) ?: return null + + return try { + contentResolver.openFileDescriptor(newFile.uri, "w")?.use { pfd -> + FileOutputStream(pfd.fileDescriptor).use { + inputStream.copyTo(it) + } + } ?: throw IOException("The descriptor could not be opened for writing!") + + status.toSavedStatus(newFile.uri, null) + } catch (e: IOException) { + if (newFile.exists()) { + newFile.delete() + } + throw e + } + } + + companion object { + const val ACCESS_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + const val SAVE_BUFFER_SIZE = 2048 + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_save_location.xml b/app/src/main/res/layout/dialog_save_location.xml index 5cf6d8a..0f4eadd 100644 --- a/app/src/main/res/layout/dialog_save_location.xml +++ b/app/src/main/res/layout/dialog_save_location.xml @@ -11,111 +11,202 @@ ~ without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. ~ See the GNU General Public License for more details. --> - + android:layout_height="wrap_content"> - - - - - - - - - - - - - + + - - + + + + + + + + + + - - + + + + + + + + + + - - - - \ No newline at end of file + android:paddingTop="@dimen/m3_list_padding_vertical" + android:paddingBottom="@dimen/m3_list_padding_vertical" + android:paddingStart="20dp" + android:paddingEnd="20dp" + android:background="?rectSelector" + android:descendantFocusability="beforeDescendants"> + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 8620d37..021b25c 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -186,6 +186,10 @@ Todos los archivos guardados se almacenan junto a las fotos de la cámara. De acuerdo al tipo de archivo Las imágenes se guardarán en Pictures y los videos se guardarán en Movies. + Ubicación personalizada + Elija sus propios directorios de guardado personalizados. + Se establecio el directorio de guardado personalizado. + No se pudo establecer el directorio de guardado personalizado. Árabe Bielorruso Holandés diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbc77a0..4878cac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -186,6 +186,10 @@ All saved files are stored in the same location as your camera photos. According to the file type Images will be saved in Pictures and videos will be saved in Movies. + Custom location + Choose your own custom save directories. + Custom save directory set. + Unable to set custom save directory. Arabic Belarusian Dutch