update UI states and refactor code

Also, add repository as Single Source of Truth for performing operations on images
This commit is contained in:
Rohit Verma 2025-01-03 23:10:04 +05:30
parent 443e713955
commit e611cbc86f
14 changed files with 262 additions and 49 deletions

View file

@ -0,0 +1,29 @@
package fr.free.nrw.commons.customselector.data
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.domain.ImageRepository
import fr.free.nrw.commons.customselector.domain.model.Image
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ImageRepositoryImpl @Inject constructor(
private val mediaReader: MediaReader,
private val notForUploadStatusDao: NotForUploadStatusDao
): ImageRepository {
override suspend fun getImagesFromDevice(): Flow<Image> {
return mediaReader.getImages()
}
override suspend fun markAsNotForUpload(imageSHA: String) {
notForUploadStatusDao.insert(NotForUploadStatus(imageSHA))
}
override suspend fun unmarkAsNotForUpload(imageSHA: String) {
notForUploadStatusDao.deleteWithImageSHA1(imageSHA)
}
override suspend fun isNotForUpload(imageSHA: String): Boolean {
return notForUploadStatusDao.find(imageSHA) > 0
}
}

View file

@ -0,0 +1,15 @@
package fr.free.nrw.commons.customselector.domain
import fr.free.nrw.commons.customselector.domain.model.Image
import kotlinx.coroutines.flow.Flow
interface ImageRepository {
suspend fun getImagesFromDevice(): Flow<Image>
suspend fun markAsNotForUpload(imageSHA: String)
suspend fun unmarkAsNotForUpload(imageSHA: String)
suspend fun isNotForUpload(imageSHA: String): Boolean
}

View file

@ -0,0 +1,89 @@
package fr.free.nrw.commons.customselector.domain.use_case
import android.content.Context
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import fr.free.nrw.commons.filepicker.PickedFiles
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.FileNotFoundException
import timber.log.Timber
import java.io.IOException
import java.net.UnknownHostException
import javax.inject.Inject
class ImageUseCase @Inject constructor(
private val fileUtilsWrapper: FileUtilsWrapper,
private val fileProcessor: FileProcessor,
private val mediaClient: MediaClient,
private val context: Context
) {
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
/**
* Retrieves the SHA1 hash of an image from its URI.
*
* @param uri The URI of the image.
* @return The SHA1 hash of the image, or an empty string if the image is not found.
*/
suspend fun getImageSHA1(uri: Uri): String = withContext(ioDispatcher) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
fileUtilsWrapper.getSHA1(inputStream)
} catch (e: FileNotFoundException) {
Timber.e(e)
""
}
}
/**
* Generates a modified SHA1 hash of an image after redacting sensitive EXIF tags.
*
* @param imageUri The URI of the image to process.
* @return The modified SHA1 hash of the image.
*/
suspend fun generateModifiedSHA1(imageUri: Uri): String = withContext(ioDispatcher) {
val uploadableFile = PickedFiles.pickedExistingPicture(context, imageUri)
val exifInterface: ExifInterface? = try {
ExifInterface(uploadableFile.file!!)
} catch (e: IOException) {
Timber.e(e)
null
}
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
val sha1 = fileUtilsWrapper.getSHA1(
fileUtilsWrapper.getFileInputStream(uploadableFile.filePath))
uploadableFile.file.delete()
sha1
}
/**
* Checks whether a file with the given SHA1 hash exists on Wikimedia Commons.
*
* @param sha1 The SHA1 hash of the file to check.
* @return An ImageLoader.Result indicating the existence of the file on Commons.
*/
suspend fun checkWhetherFileExistsOnCommonsUsingSHA1(
sha1: String
): ImageLoader.Result = withContext(ioDispatcher) {
return@withContext try {
if (mediaClient.checkFileExistsUsingSha(sha1).blockingGet()) {
ImageLoader.Result.TRUE
} else {
ImageLoader.Result.FALSE
}
} catch (e: UnknownHostException) {
Timber.e(e, "Network Connection Error")
ImageLoader.Result.ERROR
} catch (e: Exception) {
e.printStackTrace()
ImageLoader.Result.ERROR
}
}
}

View file

@ -56,12 +56,14 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
import fr.free.nrw.commons.customselector.ui.states.ImageUiState
import fr.free.nrw.commons.ui.theme.CommonsTheme
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun CustomSelectorScreen(
uiState: CustomSelectorState,
uiState: CustomSelectorUiState,
onEvent: (CustomSelectorEvent)-> Unit,
selectedImageIds: ()-> Set<Long>,
onViewImage: (id: Long)-> Unit,
@ -112,7 +114,7 @@ fun CustomSelectorScreen(
@Composable
fun FoldersPane(
uiState: CustomSelectorState,
uiState: CustomSelectorUiState,
onFolderClick: (Folder)-> Unit,
onUnselectAll: ()-> Unit,
adaptiveInfo: WindowAdaptiveInfo,
@ -270,7 +272,7 @@ private fun FolderItemPreview() {
private fun CustomSelectorScreenPreview() {
CommonsTheme {
CustomSelectorScreen(
uiState = CustomSelectorState(),
uiState = CustomSelectorUiState(),
onViewImage = { },
onEvent = { },
selectedImageIds = { emptySet() },

View file

@ -1,13 +0,0 @@
package fr.free.nrw.commons.customselector.ui.screens
import fr.free.nrw.commons.customselector.model.Image
data class CustomSelectorState(
val isLoading: Boolean = false,
val folders: List<Folder> = emptyList(),
val filteredImages: List<Image> = emptyList(),
val selectedImageIds: Set<Long> = emptySet()
) {
val inSelectionMode: Boolean
get() = selectedImageIds.isNotEmpty()
}

View file

@ -1,44 +1,67 @@
package fr.free.nrw.commons.customselector.ui.screens
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import fr.free.nrw.commons.customselector.data.MediaReader
import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.customselector.domain.ImageRepository
import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase
import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
import fr.free.nrw.commons.customselector.ui.states.ImageUiState
import fr.free.nrw.commons.customselector.ui.states.toImageUiState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel() {
typealias imageId = Long
typealias imageSHA = String
private val _uiState = MutableStateFlow(CustomSelectorState())
class CustomSelectorViewModel @Inject constructor(
private val imageRepository: ImageRepository,
private val imageUseCase: ImageUseCase
): ViewModel() {
private val _uiState = MutableStateFlow(CustomSelectorUiState())
val uiState = _uiState.asStateFlow()
private val cacheSHA1 = mutableMapOf<imageId, imageSHA>()
private val allImages = mutableListOf<ImageUiState>()
private val foldersMap = mutableMapOf<Long, MutableList<Image>>()
init {
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
mediaReader.getImages().collect { image->
imageRepository.getImagesFromDevice().collect { image ->
val bucketId = image.bucketId
allImages.add(image.toImageUiState())
foldersMap.getOrPut(bucketId) { mutableListOf() }.add(image)
}
val foldersList = foldersMap.map { (bucketId, images)->
val folders = foldersMap.map { (bucketId, images)->
val firstImage = images.first()
Folder(
bucketId = bucketId, bucketName = firstImage.bucketName,
preview = firstImage.uri, itemsCount = images.size
bucketId = bucketId,
bucketName = firstImage.bucketName,
preview = firstImage.uri,
itemsCount = images.size,
images = images
)
}
_uiState.update { it.copy(isLoading = false, folders = foldersList) }
_uiState.update { it.copy(isLoading = false, folders = folders) }
}
}
fun onEvent(e: CustomSelectorEvent) {
when(e) {
is CustomSelectorEvent.OnFolderClick-> {
is CustomSelectorEvent.OnFolderClick -> {
_uiState.update {
it.copy(filteredImages = foldersMap[e.bucketId]?.toList() ?: emptyList())
it.copy(
filteredImages = foldersMap[e.bucketId]?.map {
img -> img.toImageUiState()
} ?: emptyList()
)
}
}
@ -46,7 +69,7 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel()
_uiState.update { state ->
val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) {
state.selectedImageIds - e.imageId // Remove if already selected
} else{
} else {
state.selectedImageIds + e.imageId // Add if not selected
}
state.copy(selectedImageIds = updatedSelectedIds)

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.customselector.ui.screens
import android.net.Uri
import android.os.Parcelable
import fr.free.nrw.commons.customselector.domain.model.Image
import kotlinx.parcelize.Parcelize
@Parcelize
@ -9,5 +10,6 @@ data class Folder(
val bucketId: Long,
val bucketName: String,
val preview: Uri,
val images: List<Image>,
val itemsCount: Int
): Parcelable

View file

@ -39,6 +39,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@ -54,18 +55,20 @@ import androidx.compose.ui.unit.toIntRect
import androidx.window.core.layout.WindowWidthSizeClass
import coil.compose.rememberAsyncImagePainter
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
import fr.free.nrw.commons.customselector.ui.states.ImageUiState
import fr.free.nrw.commons.ui.theme.CommonsTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImagesPane(
uiState: CustomSelectorState,
uiState: CustomSelectorUiState,
selectedFolder: Folder,
selectedImages: () -> Set<Long>,
onNavigateBack: () -> Unit,
@ -74,7 +77,6 @@ fun ImagesPane(
adaptiveInfo: WindowAdaptiveInfo,
hasPartialAccess: Boolean = false
) {
// val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } }
val lazyGridState = rememberLazyGridState()
var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
val isCompatWidth by remember(adaptiveInfo.windowSizeClass) {
@ -255,7 +257,7 @@ private fun ImageItemPreview() {
*/
fun Modifier.imageGridDragHandler(
gridState: LazyGridState,
imageList: List<Image>,
imageList: List<ImageUiState>,
selectedImageIds: () -> Set<Long>,
autoScrollThreshold: Float,
setSelectedImageIds: (Set<Long>) -> Unit,
@ -337,7 +339,7 @@ fun Modifier.imageGridDragHandler(
* @param pointerKey The ending index of the range.
* @return A set of image IDs within the specified range.
*/
fun List<Image>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
fun List<ImageUiState>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
val setOfKeys = mutableSetOf<Long>()
if (initialKey < pointerKey) {
(initialKey..pointerKey).forEach {

View file

@ -27,13 +27,13 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.states.ImageUiState
import kotlin.math.abs
@Composable
fun ViewImageScreen(
currentImageIndex: Int,
imageList: List<Image>,
imageList: List<ImageUiState>,
) {
var imageScale by remember { mutableFloatStateOf(1f) }
var imageOffset by remember { mutableStateOf(Offset.Zero) }

View file

@ -27,21 +27,20 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.data.MediaReader
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen
import fr.free.nrw.commons.customselector.ui.screens.ViewImageScreen
import fr.free.nrw.commons.customselector.utils.CustomSelectorViewModelFactory
import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding
import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding
import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding
@ -191,17 +190,11 @@ class CustomSelectorActivity :
// setContentView(view)
prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE)
viewModel =
ViewModelProvider(this, customSelectorViewModelFactory).get(
CustomSelectorViewModel::class.java,
)
val mediaReader = MediaReader(this)
setContent {
val csViewModel = viewModel<fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel> {
fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel(mediaReader)
}
val csViewModel = ViewModelProvider(this, customSelectorViewModelFactory).get(
fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel::class.java
)
val uiState by csViewModel.uiState.collectAsStateWithLifecycle()
@ -265,7 +258,7 @@ class CustomSelectorActivity :
override fun onResume() {
super.onResume()
fetchData()
// fetchData()
}
/**

View file

@ -0,0 +1,17 @@
package fr.free.nrw.commons.customselector.ui.states
import fr.free.nrw.commons.customselector.ui.screens.Folder
import fr.free.nrw.commons.customselector.ui.screens.imageId
typealias isNotForUpload = Boolean
data class CustomSelectorUiState(
val isLoading: Boolean = true,
val folders: List<Folder> = emptyList(),
val filteredImages: List<ImageUiState> = emptyList(),
val selectedImageIds: Set<Long> = emptySet(),
val imagesNotForUpload: Map<imageId, isNotForUpload> = emptyMap()
) {
val inSelectionMode: Boolean
get() = selectedImageIds.isNotEmpty()
}

View file

@ -0,0 +1,20 @@
package fr.free.nrw.commons.customselector.ui.states
import android.net.Uri
import fr.free.nrw.commons.customselector.domain.model.Image
data class ImageUiState(
val id: Long,
val name: String,
val uri: Uri,
val bucketId: Long,
val isNotForUpload: Boolean = false,
val isUploaded: Boolean = false
)
fun Image.toImageUiState() = ImageUiState(
id = id,
name = name,
uri = uri,
bucketId = bucketId
)

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.customselector.utils
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import fr.free.nrw.commons.customselector.domain.ImageRepository
import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase
import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel
import javax.inject.Inject
class CustomSelectorViewModelFactory @Inject constructor(
private val imageRepository: ImageRepository,
private val imageUseCase: ImageUseCase
): ViewModelProvider.Factory {
override fun <CustomSelectorViewModel : ViewModel> create(
modelClass: Class<CustomSelectorViewModel>
): CustomSelectorViewModel {
return CustomSelectorViewModel(
imageRepository, imageUseCase
) as CustomSelectorViewModel
}
}

View file

@ -17,8 +17,12 @@ import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.customselector.data.ImageRepositoryImpl;
import fr.free.nrw.commons.customselector.data.MediaReader;
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao;
import fr.free.nrw.commons.customselector.database.UploadedStatusDao;
import fr.free.nrw.commons.customselector.domain.ImageRepository;
import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase;
import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
@ -317,4 +321,13 @@ public class CommonsApplicationModule {
public ContentResolver providesContentResolver(Context context){
return context.getContentResolver();
}
@Provides
public ImageRepository providesImageRepository(
MediaReader mediaReader,
NotForUploadStatusDao notForUploadStatusDao,
ImageUseCase imageUseCase
) {
return new ImageRepositoryImpl(mediaReader, notForUploadStatusDao);
}
}