Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/src/main/java/com/simplified/wsstatussaver/MainModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -58,6 +64,12 @@ private val managerModule = module {
single {
PhoneNumberUtil.createInstance(androidContext())
}
single {
WaContentStorage(androidContext(), get())
}
single {
WaSavedContentStorage(androidContext(), get())
}
single {
Storage(androidContext())
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<UriPermission>.getReadableDirectories() = WaDirectory.entries.filter { it.isReadable(this) }

fun Context.hasStoragePermissions(): Boolean = doIHavePermissions(*getApplicablePermissions())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
fun StatusType.acceptFileName(fileName: String): Boolean = !fileName.startsWith(".") && fileName.endsWith(this.format)
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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()}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<File> {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import com.simplified.wsstatussaver.storage.Storage
typealias SegmentResolver = (WaClient) -> List<String>

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)
}
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/com/simplified/wsstatussaver/model/WaFile.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,57 @@
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
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<Uri?>
private lateinit var videosDirectorySelector: ActivityResultLauncher<Uri?>

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

Expand Down
Loading