From 1d66abb7f0f8f6fd73e4676adbb6ced76aac908f Mon Sep 17 00:00:00 2001 From: mardous Date: Thu, 8 May 2025 16:08:03 -0400 Subject: [PATCH 01/13] feat(statuses): allow user to select custom save locations --- .../simplified/wsstatussaver/MainModule.kt | 14 +- .../wsstatussaver/extensions/StatusExt.kt | 5 +- .../wsstatussaver/model/SaveLocation.kt | 7 +- .../wsstatussaver/model/StatusSaveType.kt | 13 +- .../wsstatussaver/model/StatusType.kt | 37 +-- .../wsstatussaver/model/WaDirectory.kt | 18 +- .../simplified/wsstatussaver/model/WaFile.kt | 50 +++ .../SaveLocationPreferenceDialog.kt | 71 ++++- .../repository/StatusesRepository.kt | 265 +++++----------- .../storage/whatsapp/WaContentStorage.kt | 135 ++++++++ .../storage/whatsapp/WaSavedContentStorage.kt | 208 +++++++++++++ .../main/res/layout/dialog_save_location.xml | 291 ++++++++++++------ app/src/main/res/values-es-rUS/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 14 files changed, 767 insertions(+), 355 deletions(-) create mode 100644 app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt create mode 100644 app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt create mode 100644 app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt diff --git a/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt b/app/src/main/java/com/simplified/wsstatussaver/MainModule.kt index 793ccefd..b8b7a8d9 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/StatusExt.kt b/app/src/main/java/com/simplified/wsstatussaver/extensions/StatusExt.kt index 50449a70..2db39cd3 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 3d1fef17..5c2d262b 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 7806a44f..be773665 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 adc9a83a..bc1cedc6 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/StatusType.kt @@ -13,24 +13,15 @@ */ package com.simplified.wsstatussaver.model -import android.annotation.TargetApi -import android.content.ContentResolver -import android.content.ContentUris -import android.database.Cursor import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore.MediaColumns 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(@StringRes val nameRes: Int, val format: String, private val saveType: StatusSaveType) { +enum class StatusType(@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); @@ -39,30 +30,4 @@ enum class StatusType(@StringRes val nameRes: Int, val format: String, private v val contentUri: Uri get() = saveType.contentUri val mimeType: String get() = saveType.fileMimeType - - @TargetApi(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() - } - - 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 55bbcb3f..bc8feaac 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt @@ -19,13 +19,22 @@ import android.content.UriPermission import android.net.Uri import android.os.Build import android.provider.DocumentsContract +import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree +import android.provider.DocumentsContract.buildDocumentUriUsingTree +import android.provider.DocumentsContract.getTreeDocumentId import androidx.documentfile.provider.DocumentFile import com.simplified.wsstatussaver.extensions.decodedUrl import com.simplified.wsstatussaver.storage.Storage typealias SegmentResolver = (WaClient) -> List -data class WaDirectoryUri(val client: WaClient?, val uri: Uri) +data class WaDirectoryUri(val client: WaClient?, val treeId: String, val treeUri: Uri) { + fun getDocumentUri(targetId: String = getTreeDocumentId(treeUri)): Uri = + buildDocumentUriUsingTree(treeUri, targetId) + + fun getChildrenUri(targetId: String = treeId): Uri = + buildChildDocumentsUriUsingTree(treeUri, targetId) +} enum class WaDirectory( val path: String, @@ -160,12 +169,7 @@ enum class WaDirectory( it.pathRegex.matches(parts[1]) } } - directories.add( - WaDirectoryUri( - client, - DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId) - ) - ) + directories.add(WaDirectoryUri(client, documentId, treeUri)) } } } 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 00000000..6a8c5a21 --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt @@ -0,0 +1,50 @@ +/* + * 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. + */ +/* + * 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 c747807f..7f9d581e 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,47 @@ 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?) { + val isSuccess = uri?.let { waSavedContentStorage.setCustomDirectory(type, uri) } + if (isSuccess == true) { + 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 794bf46f..bbbf554f 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt @@ -15,29 +15,37 @@ package com.simplified.wsstatussaver.repository import android.content.ContentResolver import android.content.ContentUris -import android.content.ContentValues import android.content.Context import android.database.Cursor import android.media.MediaScannerConnection import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.DocumentsContract.Document import android.provider.MediaStore.MediaColumns -import androidx.annotation.RequiresApi import androidx.lifecycle.LiveData -import com.simplified.wsstatussaver.database.* -import com.simplified.wsstatussaver.extensions.* -import com.simplified.wsstatussaver.model.* +import com.simplified.wsstatussaver.database.StatusDao +import com.simplified.wsstatussaver.database.StatusEntity +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.getAllInstalledClients +import com.simplified.wsstatussaver.extensions.getPreferred +import com.simplified.wsstatussaver.extensions.getUri +import com.simplified.wsstatussaver.extensions.hasStoragePermissions +import com.simplified.wsstatussaver.extensions.isExcludeSavedStatuses +import com.simplified.wsstatussaver.extensions.preferences +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.storage.Storage -import java.io.* +import com.simplified.wsstatussaver.model.StatusType +import com.simplified.wsstatussaver.storage.whatsapp.WaSavedContentStorage +import com.simplified.wsstatussaver.storage.whatsapp.WaContentStorage +import java.io.File import java.util.Date +import java.util.concurrent.TimeUnit 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 @@ -56,60 +64,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) @@ -122,51 +82,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.uri, 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.buildChildDocumentsUriUsingTree( - directory.uri, 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)) } } } @@ -183,26 +120,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) @@ -215,18 +152,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()) { @@ -398,7 +339,7 @@ class StatusesRepositoryImpl( private fun saveAndGetUri(status: StatusEntity): Uri? { val result = runCatching { contentResolver.openInputStream(status.origin)?.use { stream -> - saveStatus(status, stream).also { saveUri -> + waSavedContentStorage.toSavedFileUri(status, stream).also { saveUri -> if (saveUri != null) { statusDao.saveStatus(status) } @@ -408,71 +349,15 @@ class StatusesRepositoryImpl( return result.getOrNull() } - @Throws(IOException::class) - private fun saveStatus(status: StatusEntity, inputStream: InputStream): Uri? { - if (IsScopedStorageRequired) { - return saveQ(status, inputStream) - } - 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 statusSaveFile.getUri() - } - } - } - return null - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun saveQ(status: StatusEntity, inputStream: InputStream): Uri? { - val contentUri = status.type.contentUri - - val values = ContentValues().apply { - put(MediaColumns.DISPLAY_NAME, status.saveName) - put(MediaColumns.RELATIVE_PATH, status.type.getRelativePath(statusSaveLocation)) - put(MediaColumns.MIME_TYPE, status.type.mimeType) - } - - var uri: Uri? = null - var stream: OutputStream? = null - val resolver = contentResolver - try { - uri = resolver.insert(contentUri, values) - if (uri != null) { - stream = resolver.openOutputStream(uri) - if (stream != null) { - inputStream.copyTo(stream, SAVE_BUFFER_SIZE) - } - resolver.notifyChange(contentUri, null) - } - } catch (e: IOException) { - if (uri != null) { - resolver.delete(uri, null, null) - } - throw e - } finally { - stream?.close() - } - return uri - } - private fun scanSavedStatuses(statusType: StatusType) { if (!IsScopedStorageRequired) { - val files = statusType.getSavesDirectory(statusSaveLocation).listFiles { _, name -> name.endsWith(statusType.format) } - ?.map { it.absolutePath } - ?.toTypedArray() - if (!files.isNullOrEmpty()) { - MediaScannerConnection.scanFile(context, files, arrayOf(statusType.mimeType), null) + waSavedContentStorage.getSaveDirectory(statusType).listFiles { _, name -> + name.endsWith(statusType.format) + }?.map { it.absolutePath }?.toTypedArray()?.let { + if (it.isNotEmpty()) { + MediaScannerConnection.scanFile(context, it, arrayOf(statusType.mimeType), null) + } } } } - - 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 00000000..be922c16 --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt @@ -0,0 +1,135 @@ +/* + * 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. + */ +/* + * 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.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 (!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 + } + + 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.getChildrenUri(), 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.buildChildDocumentsUriUsingTree( + directory.getChildrenUri(), 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 00000000..5df491a4 --- /dev/null +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -0,0 +1,208 @@ +/* + * 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 +import android.provider.DocumentsContract.createDocument +import android.provider.DocumentsContract.getTreeDocumentId +import android.provider.MediaStore.MediaColumns +import androidx.annotation.RequiresApi +import androidx.core.content.contentValuesOf +import androidx.core.content.edit +import androidx.core.net.toUri +import com.simplified.wsstatussaver.database.StatusEntity +import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired +import com.simplified.wsstatussaver.extensions.getUri +import com.simplified.wsstatussaver.extensions.preferences +import com.simplified.wsstatussaver.extensions.saveLocation +import com.simplified.wsstatussaver.model.SaveLocation +import com.simplified.wsstatussaver.model.StatusType +import com.simplified.wsstatussaver.model.WaDirectory +import com.simplified.wsstatussaver.model.WaDirectoryUri +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream + +class WaSavedContentStorage(context: Context, private val contentResolver: ContentResolver) { + + private val preferences = context.preferences() + private val currentSaveLocation: SaveLocation + get() = preferences.saveLocation + + fun getCustomDirectoryName(type: StatusType): String { + val saveDirectory = getCustomSaveDirectory(type, SaveLocation.Custom) + ?: return type.saveType.dirName + + val nameSelection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + return contentResolver.query(saveDirectory.getDocumentUri(), nameSelection, null, null, null).use { cursor -> + cursor?.takeIf { it.moveToFirst() } + ?.getString(cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) + ?: type.saveType.dirName + } + } + + fun setCustomDirectory(type: StatusType, selectedUri: Uri): Boolean { + if (DocumentsContract.isTreeUri(selectedUri)) { + if (WaDirectory.entries.any { it.isThis(selectedUri) }) { + return false + } + contentResolver.takePersistableUriPermission( + selectedUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + preferences.edit { + putString(type.saveType.customDirectoryId, 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 (DocumentsContract.isTreeUri(treeUri)) { + return WaDirectoryUri(null, getTreeDocumentId(treeUri), treeUri) + } + } + } + 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 toSavedFileUri(status: StatusEntity, inputStream: InputStream): Uri? { + val customSaveDirectory = getCustomSaveDirectory(status.type) + if (customSaveDirectory != null) { + return toUriLocation(status, inputStream, customSaveDirectory.treeUri) + } + if (IsScopedStorageRequired) { + return toMediaStore(status, inputStream) + } + return toFileLocation(status, inputStream) + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun toMediaStore( + status: StatusEntity, + inputStream: InputStream + ): Uri? { + val contentUri = status.type.contentUri + val values = 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 + try { + uri = contentResolver.insert(contentUri, values) + if (uri != null) { + contentResolver.openOutputStream(uri)?.use { outputStream -> + inputStream.copyTo(outputStream, SAVE_BUFFER_SIZE) + } + contentResolver.notifyChange(contentUri, null) + } + } catch (e: IOException) { + if (uri != null) { + contentResolver.delete(uri, null, null) + } + throw e + } + return uri + } + + fun toFileLocation(status: StatusEntity, inputStream: InputStream): Uri? { + 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 statusSaveFile.getUri() + } + } + } + return null + } + + fun toUriLocation( + status: StatusEntity, + inputStream: InputStream, + directoryUri: Uri + ): Uri? { + val documentUri = try { + createDocument(contentResolver, directoryUri, status.type.mimeType, status.saveName) + } catch (e: FileNotFoundException) { + e.printStackTrace() + null + } + if (documentUri != null) { + try { + contentResolver.openOutputStream(documentUri)?.use { outputStream -> + inputStream.copyTo(outputStream, SAVE_BUFFER_SIZE) + } + } catch (e: IOException) { + DocumentsContract.deleteDocument(contentResolver, documentUri) + throw e + } + } + return documentUri + } + + companion object { + 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 5cf6d8ae..0f4eaddc 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 2fe10271..32f048e4 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -187,6 +187,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 1d4c9715..8f8f7409 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,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 From 25eafae0cdfe4e973a5f1d385e75d56d329134ab Mon Sep 17 00:00:00 2001 From: mardous Date: Thu, 8 May 2025 17:59:34 -0400 Subject: [PATCH 02/13] refactor: update directory handling for saved content --- .../java/com/simplified/wsstatussaver/model/WaDirectory.kt | 6 ++---- .../wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) 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 9f06a3dd..4d530ddc 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaDirectory.kt @@ -19,8 +19,6 @@ import android.content.UriPermission import android.net.Uri import android.os.Build import android.provider.DocumentsContract -import android.provider.DocumentsContract.buildDocumentUriUsingTree -import android.provider.DocumentsContract.getTreeDocumentId import androidx.documentfile.provider.DocumentFile import com.simplified.wsstatussaver.extensions.decodedUrl import com.simplified.wsstatussaver.storage.Storage @@ -28,8 +26,8 @@ import com.simplified.wsstatussaver.storage.Storage typealias SegmentResolver = (WaClient) -> List data class WaDirectoryUri(val client: WaClient?, val treeUri: Uri, private val documentId: String) { - fun getDocumentUri(targetId: String = getTreeDocumentId(treeUri)): Uri = - buildDocumentUriUsingTree(treeUri, targetId) + 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/storage/whatsapp/WaSavedContentStorage.kt b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt index 5df491a4..d25edcc3 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -53,7 +53,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte ?: return type.saveType.dirName val nameSelection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) - return contentResolver.query(saveDirectory.getDocumentUri(), nameSelection, null, null, null).use { cursor -> + return contentResolver.query(saveDirectory.documentUri, nameSelection, null, null, null).use { cursor -> cursor?.takeIf { it.moveToFirst() } ?.getString(cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) ?: type.saveType.dirName @@ -83,7 +83,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte if (!savedValue.isNullOrBlank()) { val treeUri = savedValue.toUri() if (DocumentsContract.isTreeUri(treeUri)) { - return WaDirectoryUri(null, getTreeDocumentId(treeUri), treeUri) + return WaDirectoryUri(null, treeUri, getTreeDocumentId(treeUri)) } } } From 4a2ef323607deaab98b926bc2c43dc6d7cf99a98 Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:31:36 -0400 Subject: [PATCH 03/13] refactor(app): simplify null check in setCustomDirectoryLocation The previous implementation used `uri?.let { ... }` which is not necessary. This change removes the unnecessary let and directly handles the null check for the `uri`. --- .../preferences/SaveLocationPreferenceDialog.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 7f9d581e..f66f0ebc 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/preferences/SaveLocationPreferenceDialog.kt @@ -88,8 +88,11 @@ class SaveLocationPreferenceDialog : DialogFragment(), View.OnClickListener { } private fun setCustomDirectoryLocation(type: StatusType, uri: Uri?) { - val isSuccess = uri?.let { waSavedContentStorage.setCustomDirectory(type, uri) } - if (isSuccess == true) { + if (uri == null) + return + + val isSuccess = waSavedContentStorage.setCustomDirectory(type, uri) + if (isSuccess) { showToast(R.string.custom_save_directory_set) updateCustomDirectoryNames() } else { From 473d374885b30c09b51a390e09c706686cf435b9 Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:33:29 -0400 Subject: [PATCH 04/13] refactor: allow clearing custom save directory This change modifies the `setCustomDirectory` function to accept a nullable Uri. Passing `null` to this function will remove the stored custom directory preference for the specified status type. --- .../storage/whatsapp/WaSavedContentStorage.kt | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 index d25edcc3..2ebb9aef 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -60,19 +60,23 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte } } - fun setCustomDirectory(type: StatusType, selectedUri: Uri): Boolean { - if (DocumentsContract.isTreeUri(selectedUri)) { - if (WaDirectory.entries.any { it.isThis(selectedUri) }) { - return false - } - contentResolver.takePersistableUriPermission( - selectedUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - preferences.edit { - putString(type.saveType.customDirectoryId, selectedUri.toString()) + fun setCustomDirectory(type: StatusType, selectedUri: Uri?): Boolean { + if (selectedUri == null) { + preferences.edit { remove(type.saveType.customDirectoryId) } + } else { + if (DocumentsContract.isTreeUri(selectedUri)) { + if (WaDirectory.entries.any { it.isThis(selectedUri) }) { + return false + } + contentResolver.takePersistableUriPermission( + selectedUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + preferences.edit { + putString(type.saveType.customDirectoryId, selectedUri.toString()) + } + return true } - return true } return false } @@ -84,6 +88,8 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte val treeUri = savedValue.toUri() if (DocumentsContract.isTreeUri(treeUri)) { return WaDirectoryUri(null, treeUri, getTreeDocumentId(treeUri)) + } else { + setCustomDirectory(type, null) } } } From e6e44876f54ca1d6fd6abb9284fa550ce78e95c8 Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:34:28 -0400 Subject: [PATCH 05/13] refactor: check for full access when retrieving custom directory The change refactors the `getCustomDirectory` function to check if the application has full access (read and write permissions) for the persisted URI when retrieving a custom directory. This ensures that the application can both read from and write to the specified custom directory. A helper extension function `hasFullAccess` is introduced for this check. --- .../simplified/wsstatussaver/extensions/PermissionExt.kt | 2 ++ .../wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 64815846..c4d8eb28 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt @@ -61,6 +61,8 @@ fun Uri.toWhatsAppDirectory() = WaDirectory.entries.firstOrNull { it.isThis(this fun Context.getReadableDirectories() = contentResolver.persistedUriPermissions.getReadableDirectories() +fun UriPermission.hasFullAccess(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/storage/whatsapp/WaSavedContentStorage.kt b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt index 2ebb9aef..1897f3d3 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -31,6 +31,7 @@ import androidx.core.net.toUri import com.simplified.wsstatussaver.database.StatusEntity import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired import com.simplified.wsstatussaver.extensions.getUri +import com.simplified.wsstatussaver.extensions.hasFullAccess import com.simplified.wsstatussaver.extensions.preferences import com.simplified.wsstatussaver.extensions.saveLocation import com.simplified.wsstatussaver.model.SaveLocation @@ -86,7 +87,10 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte val savedValue = preferences.getString(type.saveType.customDirectoryId, null) if (!savedValue.isNullOrBlank()) { val treeUri = savedValue.toUri() - if (DocumentsContract.isTreeUri(treeUri)) { + val hasPermission = contentResolver.persistedUriPermissions.any { + it.hasFullAccess(treeUri) + } + if (DocumentsContract.isTreeUri(treeUri) && hasPermission) { return WaDirectoryUri(null, treeUri, getTreeDocumentId(treeUri)) } else { setCustomDirectory(type, null) From b65b1b3f7a15c5880345f67d4a362aa4b4db311c Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:36:08 -0400 Subject: [PATCH 06/13] refactor(android): improve file saving logic in WaSavedContentStorage This change refactors the `toUriLocation` function in `WaSavedContentStorage` to accept a `WaDirectoryUri` object instead of just a `Uri`. This change provides more context about the directory when creating a new document using `createDocument`, making the file saving logic more robust. --- .../wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 1897f3d3..8fd53b19 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -135,7 +135,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte fun toSavedFileUri(status: StatusEntity, inputStream: InputStream): Uri? { val customSaveDirectory = getCustomSaveDirectory(status.type) if (customSaveDirectory != null) { - return toUriLocation(status, inputStream, customSaveDirectory.treeUri) + return toCustomDirectory(status, inputStream, customSaveDirectory) } if (IsScopedStorageRequired) { return toMediaStore(status, inputStream) @@ -191,10 +191,10 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte fun toUriLocation( status: StatusEntity, inputStream: InputStream, - directoryUri: Uri + directory: WaDirectoryUri ): Uri? { val documentUri = try { - createDocument(contentResolver, directoryUri, status.type.mimeType, status.saveName) + createDocument(contentResolver, directory.documentUri, status.type.mimeType, status.saveName) } catch (e: FileNotFoundException) { e.printStackTrace() null From c3cdbbe34bcade2a766e072556d092305565b6a8 Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:36:30 -0400 Subject: [PATCH 07/13] refactor: make storage methods private The `toMediaStore`, `toFileLocation`, and `toCustomDirectory` methods in `WaSavedContentStorage` have been made private as they are only intended for internal use within the class. --- .../wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 8fd53b19..25e26cfd 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -144,7 +144,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte } @RequiresApi(Build.VERSION_CODES.Q) - fun toMediaStore( + private fun toMediaStore( status: StatusEntity, inputStream: InputStream ): Uri? { @@ -172,7 +172,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte return uri } - fun toFileLocation(status: StatusEntity, inputStream: InputStream): Uri? { + private fun toFileLocation(status: StatusEntity, inputStream: InputStream): Uri? { if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) { val destDirectory = getSaveDirectory(status.type) if (destDirectory.isDirectory || destDirectory.mkdirs()) { @@ -188,7 +188,7 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte return null } - fun toUriLocation( + private fun toCustomDirectory( status: StatusEntity, inputStream: InputStream, directory: WaDirectoryUri From b96fbeb7d5a3277addc1749e74086af400216ff2 Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:38:25 -0400 Subject: [PATCH 08/13] remove duplicate license header --- .../storage/whatsapp/WaContentStorage.kt | 13 ------------- 1 file changed, 13 deletions(-) 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 index 6f31de04..b2068a84 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt @@ -1,16 +1,3 @@ -/* - * 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. - */ /* * Copyright (C) 2025 Christians Martínez Alvarado * From be252b54190993015f190917b858c77ca47fcf0a Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 10:42:12 -0400 Subject: [PATCH 09/13] force unmerged changes from 'master' --- .../repository/StatusesRepository.kt | 74 ++----------------- 1 file changed, 8 insertions(+), 66 deletions(-) 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 bbbf554f..362378ed 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/repository/StatusesRepository.kt @@ -29,7 +29,6 @@ import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired import com.simplified.wsstatussaver.extensions.acceptFileName import com.simplified.wsstatussaver.extensions.getAllInstalledClients import com.simplified.wsstatussaver.extensions.getPreferred -import com.simplified.wsstatussaver.extensions.getUri import com.simplified.wsstatussaver.extensions.hasStoragePermissions import com.simplified.wsstatussaver.extensions.isExcludeSavedStatuses import com.simplified.wsstatussaver.extensions.preferences @@ -39,10 +38,8 @@ 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.storage.whatsapp.WaSavedContentStorage import com.simplified.wsstatussaver.storage.whatsapp.WaContentStorage -import java.io.File -import java.util.Date +import com.simplified.wsstatussaver.storage.whatsapp.WaSavedContentStorage import java.util.concurrent.TimeUnit interface StatusesRepository { @@ -196,73 +193,18 @@ class StatusesRepositoryImpl( } override suspend fun share(status: Status): ShareData { - if (IsSAFRequired && status !is SavedStatus) { - val cacheDir = context.externalCacheDir - if (cacheDir == null || (!cacheDir.exists() && !cacheDir.mkdirs())) { - return ShareData(status.fileUri, status.type.mimeType) - } - val temp = File(cacheDir, status.type.getDefaultSaveName(Date().time, 0)) - if (!temp.exists() || temp.delete()) { - try { - val inputStream = contentResolver.openInputStream(status.fileUri) - if (inputStream != null) { - inputStream.use { - temp.outputStream().use { outputStream -> - it.copyTo(outputStream) - } - } - return ShareData(temp.getUri(), status.type.mimeType) - } - } catch (e: Exception) { - e.printStackTrace() - } - } - return ShareData.Empty - } return ShareData(status.fileUri, status.type.mimeType) } override suspend fun share(statuses: List): ShareData { - if (IsSAFRequired) { - val data = hashMapOf() - val savedStatuses = statuses.filterIsInstance().toSet() - val unsavedStatuses = statuses.subtract(savedStatuses) - for (status in savedStatuses) { - data[status.fileUri] = status.type.mimeType - } - if (unsavedStatuses.isEmpty()) { - return ShareData(data.keys, data.values.toSet()) - } - val cacheDir = context.externalCacheDir - if (cacheDir != null && (cacheDir.exists() || cacheDir.mkdirs())) { - val currentTime = Date().time - for ((i, status) in unsavedStatuses.withIndex()) { - val temp = File(cacheDir, status.type.getDefaultSaveName(currentTime, i + 1)) - if (!temp.exists() || temp.delete()) { - try { - val inputStream = contentResolver.openInputStream(status.fileUri) - if (inputStream != null) { - inputStream.use { - temp.outputStream().use { outputStream -> - it.copyTo(outputStream) - } - } - data[temp.getUri()] = status.type.mimeType - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - return ShareData(data.keys, data.values.toSet()) - } - return ShareData.Empty + val data = hashMapOf() + for (status in statuses) { + data[status.fileUri] = status.type.mimeType + } + return if (data.isEmpty()) { + ShareData.Empty } else { - val data = hashMapOf() - for (status in statuses) { - data[status.fileUri] = status.type.mimeType - } - return ShareData(data.keys, data.values.toSet()) + ShareData(data.keys, data.values.toSet()) } } From 2101f9fb34c20479aad7706bcda654ee66d3b15c Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 17:50:38 -0400 Subject: [PATCH 10/13] refactor: improve custom directory handling and permission checks --- .../wsstatussaver/extensions/PermissionExt.kt | 26 +++++++++- .../storage/whatsapp/WaContentStorage.kt | 3 +- .../storage/whatsapp/WaSavedContentStorage.kt | 51 ++++++++++--------- 3 files changed, 55 insertions(+), 25 deletions(-) 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 c4d8eb28..88f4f3ac 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt @@ -19,6 +19,7 @@ import android.Manifest.permission.READ_MEDIA_VIDEO import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.annotation.SuppressLint import android.app.Activity +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.UriPermission @@ -37,6 +38,7 @@ import androidx.navigation.findNavController import com.simplified.wsstatussaver.R import com.simplified.wsstatussaver.model.RequestedPermissions import com.simplified.wsstatussaver.model.WaDirectory +import com.simplified.wsstatussaver.recordException const val STORAGE_PERMISSION_REQUEST = 100 @@ -57,11 +59,33 @@ 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 UriPermission.hasFullAccess(against: Uri) = uri == against && isWritePermission && isReadPermission +fun ContentResolver.takePermissions(uri: Uri, flags: Int): Boolean { + val result = runCatching { takePersistableUriPermission(uri, flags) } + if (result.isFailure) { + result.exceptionOrNull()?.let { recordException(it) } + } + 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) } 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 index b2068a84..a9967478 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaContentStorage.kt @@ -20,6 +20,7 @@ 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 @@ -50,7 +51,7 @@ class WaContentStorage( return directories } for (perm in persistedPermissions) { - if (!DocumentsContract.isTreeUri(perm.uri)) continue + if (!perm.uri.isTreeUri()) continue val matchingDir = readableDirectories.firstOrNull { it.isThis(perm.uri) } if (matchingDir != null) { directories.addAll(matchingDir.getStatusesDirectories(context, clients, perm.uri)) 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 index 25e26cfd..a2f30b5b 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -31,12 +31,15 @@ import androidx.core.net.toUri import com.simplified.wsstatussaver.database.StatusEntity import com.simplified.wsstatussaver.extensions.IsScopedStorageRequired import com.simplified.wsstatussaver.extensions.getUri -import com.simplified.wsstatussaver.extensions.hasFullAccess +import com.simplified.wsstatussaver.extensions.allPermissionsGranted +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.StatusType -import com.simplified.wsstatussaver.model.WaDirectory import com.simplified.wsstatussaver.model.WaDirectoryUri import java.io.File import java.io.FileNotFoundException @@ -61,24 +64,29 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte } } - fun setCustomDirectory(type: StatusType, selectedUri: Uri?): Boolean { - if (selectedUri == null) { - preferences.edit { remove(type.saveType.customDirectoryId) } - } else { - if (DocumentsContract.isTreeUri(selectedUri)) { - if (WaDirectory.entries.any { it.isThis(selectedUri) }) { - return false - } - contentResolver.takePersistableUriPermission( - selectedUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - preferences.edit { - putString(type.saveType.customDirectoryId, selectedUri.toString()) - } - return true + 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 } @@ -87,13 +95,10 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte val savedValue = preferences.getString(type.saveType.customDirectoryId, null) if (!savedValue.isNullOrBlank()) { val treeUri = savedValue.toUri() - val hasPermission = contentResolver.persistedUriPermissions.any { - it.hasFullAccess(treeUri) - } - if (DocumentsContract.isTreeUri(treeUri) && hasPermission) { + if (treeUri.isCustomSaveDirectory(contentResolver)) { return WaDirectoryUri(null, treeUri, getTreeDocumentId(treeUri)) } else { - setCustomDirectory(type, null) + preferences.edit { remove(type.saveType.customDirectoryId) } } } } From 393ec67416a705ae5ad0681ef807f806892b345b Mon Sep 17 00:00:00 2001 From: mardous Date: Fri, 9 May 2025 18:33:44 -0400 Subject: [PATCH 11/13] refactor(storage): Improve saving content in scoped storage This change refactors the content saving logic in `WaSavedContentStorage` to use `DocumentFile` and `FileOutputStream` for better handling within scoped storage. This replaces the previous approach using `DocumentsContract.createDocument` and `openOutputStream`, which was less robust for writing content. Additionally, the method for getting the custom directory name now also utilizes `DocumentFile`. --- .../storage/whatsapp/WaSavedContentStorage.kt | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) 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 index a2f30b5b..1b8711b4 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/storage/whatsapp/WaSavedContentStorage.kt @@ -20,14 +20,13 @@ import android.database.Cursor import android.net.Uri import android.os.Build import android.os.Environment -import android.provider.DocumentsContract -import android.provider.DocumentsContract.createDocument import android.provider.DocumentsContract.getTreeDocumentId import android.provider.MediaStore.MediaColumns 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.extensions.IsScopedStorageRequired import com.simplified.wsstatussaver.extensions.getUri @@ -42,26 +41,20 @@ import com.simplified.wsstatussaver.model.SaveLocation import com.simplified.wsstatussaver.model.StatusType import com.simplified.wsstatussaver.model.WaDirectoryUri import java.io.File -import java.io.FileNotFoundException +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream -class WaSavedContentStorage(context: Context, private val contentResolver: ContentResolver) { +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 { - val saveDirectory = getCustomSaveDirectory(type, SaveLocation.Custom) - ?: return type.saveType.dirName - - val nameSelection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) - return contentResolver.query(saveDirectory.documentUri, nameSelection, null, null, null).use { cursor -> - cursor?.takeIf { it.moveToFirst() } - ?.getString(cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) - ?: type.saveType.dirName - } + return getCustomSaveDirectory(type, SaveLocation.Custom)?.let { + DocumentFile.fromTreeUri(context, it.treeUri)?.name + } ?: type.saveType.dirName } fun setCustomDirectory(type: StatusType, selectedUri: Uri): Boolean { @@ -198,26 +191,27 @@ class WaSavedContentStorage(context: Context, private val contentResolver: Conte inputStream: InputStream, directory: WaDirectoryUri ): Uri? { - val documentUri = try { - createDocument(contentResolver, directory.documentUri, status.type.mimeType, status.saveName) - } catch (e: FileNotFoundException) { - e.printStackTrace() - null - } - if (documentUri != null) { - try { - contentResolver.openOutputStream(documentUri)?.use { outputStream -> - inputStream.copyTo(outputStream, SAVE_BUFFER_SIZE) + 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) } - } catch (e: IOException) { - DocumentsContract.deleteDocument(contentResolver, documentUri) - throw e + } ?: throw IOException("The descriptor could not be opened for writing!") + + newFile.uri + } catch (e: IOException) { + if (newFile.exists()) { + newFile.delete() } + throw e } - return documentUri } 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 From 46ef649d6143c5ac976b8319fedff916df875278 Mon Sep 17 00:00:00 2001 From: Christian <37917778+mardous@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:28:41 -0400 Subject: [PATCH 12/13] chore: remove duplicate license header --- .../com/simplified/wsstatussaver/model/WaFile.kt | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt b/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt index 6a8c5a21..8936f1c2 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt @@ -1,16 +1,3 @@ -/* - * 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. - */ /* * Copyright (C) 2025 Christians Martínez Alvarado * From 7318f347553fd71a920240d9d15944cdd1bae77d Mon Sep 17 00:00:00 2001 From: Christian <37917778+mardous@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:53:05 -0400 Subject: [PATCH 13/13] fix: remove firebase crashlytics dependency --- .../com/simplified/wsstatussaver/extensions/PermissionExt.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 35142ff8..e49e6b38 100644 --- a/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt +++ b/app/src/main/java/com/simplified/wsstatussaver/extensions/PermissionExt.kt @@ -32,7 +32,6 @@ import androidx.navigation.findNavController import com.simplified.wsstatussaver.R import com.simplified.wsstatussaver.model.RequestedPermissions import com.simplified.wsstatussaver.model.WaDirectory -import com.simplified.wsstatussaver.recordException const val STORAGE_PERMISSION_REQUEST = 100 @@ -71,7 +70,7 @@ fun Context.getReadableDirectories() = contentResolver.persistedUriPermissions.g fun ContentResolver.takePermissions(uri: Uri, flags: Int): Boolean { val result = runCatching { takePersistableUriPermission(uri, flags) } if (result.isFailure) { - result.exceptionOrNull()?.let { recordException(it) } + result.exceptionOrNull()?.printStackTrace() } return result.isSuccess }