diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt index b46d1087e..6b895232f 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt @@ -39,7 +39,8 @@ data class Contribution constructor( var dataLength: Long = 0, var dateCreated: Date? = null, var dateModified: Date? = null, - var hasInvalidLocation : Int = 0 + var hasInvalidLocation : Int = 0, + var contentUri: Uri? = null ) : Parcelable { fun completeWith(media: Media): Contribution { @@ -64,7 +65,8 @@ data class Contribution constructor( decimalCoords = item.gpsCoords.decimalCoords, dateCreatedSource = "", depictedItems = depictedItems, - wikidataPlace = from(item.place) + wikidataPlace = from(item.place), + contentUri = item.contentUri ) /** diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt new file mode 100644 index 000000000..d9f2fc55e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedDao.kt @@ -0,0 +1,88 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.* +import kotlinx.coroutines.runBlocking +import java.util.* +import kotlinx.coroutines.* + +/** + * UploadedStatusDao for Custom Selector. + */ +@Dao +abstract class UploadedStatusDao { + + /** + * Insert into uploaded status. + */ + @Insert( onConflict = OnConflictStrategy.REPLACE ) + abstract suspend fun insert(uploadedStatus: UploadedStatus) + + /** + * Update uploaded status entry. + */ + @Update + abstract suspend fun update(uploadedStatus: UploadedStatus) + + /** + * Delete uploaded status entry. + */ + @Delete + abstract suspend fun delete(uploadedStatus: UploadedStatus) + + /** + * Get All entries from the uploaded status table. + */ + @Query("SELECT * FROM uploaded_table") + abstract suspend fun getAll() : List + + /** + * Query uploaded status with image sha1. + */ + @Query("SELECT * FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) ") + abstract suspend fun getFromImageSHA1(imageSHA1 : String) : UploadedStatus + + /** + * Query uploaded status with modified image sha1. + */ + @Query("SELECT * FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) ") + abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1 : String) : UploadedStatus + + /** + * Asynchronous insert into uploaded status table. + */ + fun insertUploaded(uploadedStatus: UploadedStatus) = runBlocking { + async { + uploadedStatus.lastUpdated = Calendar.getInstance().time as Date? + insert(uploadedStatus) + }.await() + } + + /** + * Asynchronous delete from uploaded status table. + */ + fun deleteUploaded(uploadedStatus: UploadedStatus) = runBlocking { + async { delete(uploadedStatus) } + } + + /** + * Asynchronous update entry in uploaded status table. + */ + fun updateUploaded(uploadedStatus: UploadedStatus) = runBlocking { + async { update(uploadedStatus) } + } + + /** + * Asynchronous image sha1 query. + */ + fun getUploadedFromImageSHA1(imageSHA1: String) = runBlocking { + async { getFromImageSHA1(imageSHA1) }.await() + } + + /** + * Asynchronous modified image sha1 query. + */ + fun getUploadedFromModifiedImageSHA1(modifiedImageSHA1: String) = runBlocking { + async { getFromModifiedImageSHA1(modifiedImageSHA1) }.await() + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt new file mode 100644 index 000000000..93e4a8243 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/database/UploadedStatus.kt @@ -0,0 +1,39 @@ +package fr.free.nrw.commons.customselector.database + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.* + +/** + * Entity class for Uploaded Status. + */ +@Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)]) +data class UploadedStatus( + + /** + * Original image sha1. + */ + @PrimaryKey + val imageSHA1 : String, + + /** + * Modified image sha1 (after exif changes). + */ + val modifiedImageSHA1 : String, + + /** + * imageSHA1 query result from API. + */ + var imageResult : Boolean, + + /** + * modifiedImageSHA1 query result from API. + */ + var modifiedImageResult : Boolean, + + /** + * lastUpdated for data validation. + */ + var lastUpdated : Date? = null +) diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt index a3ae38e34..f2d4d5709 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt @@ -1,7 +1,10 @@ package fr.free.nrw.commons.customselector.ui.selector import android.content.Context +import android.net.Uri import androidx.exifinterface.media.ExifInterface +import fr.free.nrw.commons.customselector.database.UploadedStatus +import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter.ImageViewHolder import fr.free.nrw.commons.filepicker.PickedFiles @@ -14,6 +17,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.io.IOException +import java.net.UnknownHostException import java.util.* import javax.inject.Inject import kotlin.collections.HashMap @@ -38,22 +42,28 @@ class ImageLoader @Inject constructor( */ var fileUtilsWrapper: FileUtilsWrapper, + /** + * UploadedStatusDao for cache query. + */ + var uploadedStatusDao: UploadedStatusDao, + /** * Context for coroutine. */ - val context: Context) { + val context: Context +) { /** * Maps to facilitate image query. */ - private var mapImageSHA1: HashMap = HashMap() - private var mapHolderImage : HashMap = HashMap() - private var mapResult: HashMap = HashMap() + private var mapImageSHA1: HashMap = HashMap() + private var mapHolderImage : HashMap = HashMap() + private var mapResult: HashMap = HashMap() /** * Query image and setUp the view. */ - fun queryAndSetView(holder: ImageViewHolder, image: Image){ + fun queryAndSetView(holder: ImageViewHolder, image: Image) { /** * Recycler view uses same view holder, so we can identify the latest query image from holder. @@ -62,26 +72,46 @@ class ImageLoader @Inject constructor( holder.itemNotUploaded() CoroutineScope(Dispatchers.Main).launch { - var value = false + + var result : Result = Result.NOTFOUND withContext(Dispatchers.Default) { - if(mapHolderImage[holder] != image) { - // View holder has a new query image, terminate this query. - return@withContext + + if (mapHolderImage[holder] == image) { + val imageSHA1 = getImageSHA1(image.uri) + val uploadedStatus = uploadedStatusDao.getUploadedFromImageSHA1(imageSHA1) + + val sha1 = uploadedStatus?.let { + result = getResultFromUploadedStatus(uploadedStatus) + uploadedStatus.modifiedImageSHA1 + } ?: run { + if(mapHolderImage[holder] == image) { + getSHA1(image) + } else { + "" + } + } + + if (mapHolderImage[holder] == image && + result in arrayOf(Result.NOTFOUND, Result.INVALID) && + sha1.isNotEmpty()) { + // Query original image. + result = querySHA1(imageSHA1) + if( result is Result.TRUE ) { + // Original image found. + insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false) + } + else { + // Original image not found, query modified image. + result = querySHA1(sha1) + if (result != Result.ERROR) { + insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE) + } + } + } } - val sha1 = getSHA1(image) - if(mapHolderImage[holder] != image) { - // View holder has a new query image, terminate this query. - return@withContext - } - value = querySHA1(sha1) } if(mapHolderImage[holder] == image) { - // View holder and latest query image match, setup the view. - if (value) { - holder.itemUploaded() - } else { - holder.itemNotUploaded() - } + if (result is Result.TRUE) holder.itemUploaded() else holder.itemNotUploaded() } } } @@ -91,13 +121,26 @@ class ImageLoader @Inject constructor( * * @return Query result. */ - private fun querySHA1(SHA1: String): Boolean { - if(mapResult[SHA1] != null) { - return mapResult[SHA1]!! + private fun querySHA1(SHA1: String): Result { + mapResult[SHA1]?.let{ + return it + } + var result : Result = Result.FALSE + try { + if (mediaClient.checkFileExistsUsingSha(SHA1).blockingGet()) { + mapResult[SHA1] = Result.TRUE + result = Result.TRUE + } + } catch (e: Exception) { + if (e is UnknownHostException) { + // Handle no network connection. + Timber.e(e, "Network Connection Error") + } + result = Result.ERROR + e.printStackTrace() + } finally { + return result } - val isUploaded = mediaClient.checkFileExistsUsingSha(SHA1).blockingGet() - mapResult[SHA1] = isUploaded - return isUploaded } /** @@ -105,15 +148,45 @@ class ImageLoader @Inject constructor( * * @return sha1 of the image */ - private fun getSHA1(image: Image): String{ - if(mapImageSHA1[image] != null) { - return mapImageSHA1[image]!! + private fun getSHA1(image: Image): String { + mapImageSHA1[image]?.let{ + return it } val sha1 = generateModifiedSHA1(image); mapImageSHA1[image] = sha1; return sha1; } + /** + * Insert into uploaded status table. + */ + private fun insertIntoUploaded(imageSha1:String, modifiedImageSha1:String, imageResult:Boolean, modifiedImageResult: Boolean){ + uploadedStatusDao.insertUploaded(UploadedStatus(imageSha1, modifiedImageSha1, imageResult, modifiedImageResult)) + } + + /** + * Get image sha1 from uri, used to retrieve the original image sha1. + */ + private fun getImageSHA1(uri: Uri): String { + return fileUtilsWrapper.getSHA1(context.contentResolver.openInputStream(uri)) + } + + /** + * Get result data from database. + */ + private fun getResultFromUploadedStatus(uploadedStatus: UploadedStatus): Result { + if (uploadedStatus.imageResult || uploadedStatus.modifiedImageResult) { + return Result.TRUE + } else { + uploadedStatus.lastUpdated?.let { + if (it.date >= Calendar.getInstance().time.date - INVALIDATE_DAY_COUNT) { + return Result.FALSE + } + } + } + return Result.INVALID + } + /** * Generate Modified SHA1 using present Exif settings. * @@ -133,4 +206,19 @@ class ImageLoader @Inject constructor( return sha1 } + /** + * Sealed Result class. + */ + sealed class Result { + object TRUE : Result() + object FALSE : Result() + object INVALID : Result() + object NOTFOUND : Result() + object ERROR : Result() + } + + companion object { + const val INVALIDATE_DAY_COUNT: Int = 7 + } + } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt index f06e15da8..a534feb93 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -5,6 +5,8 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.customselector.database.UploadedStatus +import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.upload.depicts.Depicts import fr.free.nrw.commons.upload.depicts.DepictsDao @@ -12,9 +14,10 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao * The database for accessing the respective DAOs * */ -@Database(entities = [Contribution::class,Depicts::class], version = 7, exportSchema = false) +@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class], version = 8, exportSchema = false) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun contributionDao(): ContributionDao abstract fun DepictsDao (): DepictsDao; + abstract fun UploadedStatusDao(): UploadedStatusDao; } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index bca71de98..7d9c061ff 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -17,6 +17,7 @@ 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.database.UploadedStatusDao; import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader; import fr.free.nrw.commons.data.DBOpenHelper; import fr.free.nrw.commons.db.AppDatabase; @@ -68,8 +69,8 @@ public class CommonsApplicationModule { } @Provides - public ImageFileLoader providesImageFileLoader() { - return new ImageFileLoader(this.applicationContext); + public ImageFileLoader providesImageFileLoader(Context context) { + return new ImageFileLoader(context); } @Provides @@ -250,13 +251,21 @@ public class CommonsApplicationModule { } /** - * Get the reference of DepictsDao class + * Get the reference of DepictsDao class. */ @Provides public DepictsDao providesDepictDao(AppDatabase appDatabase) { return appDatabase.DepictsDao(); } + /** + * Get the reference of UploadedStatus class. + */ + @Provides + public UploadedStatusDao providesUploadedStatusDao(AppDatabase appDatabase) { + return appDatabase.UploadedStatusDao(); + } + @Provides public ContentResolver providesContentResolver(Context context){ return context.getContentResolver(); diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java index 0d76ba57c..4d9d341b5 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.java @@ -23,6 +23,7 @@ public class UploadItem { private final String createdTimestampSource; private final BehaviorSubject imageQuality; private boolean hasInvalidLocation; + private final Uri contentUri; @SuppressLint("CheckResult") @@ -31,7 +32,8 @@ public class UploadItem { final ImageCoordinates gpsCoords, final Place place, final long createdTimestamp, - final String createdTimestampSource) { + final String createdTimestampSource, + final Uri contentUri) { this.createdTimestampSource = createdTimestampSource; uploadMediaDetails = new ArrayList<>(Collections.singletonList(new UploadMediaDetail())); this.place = place; @@ -39,6 +41,7 @@ public class UploadItem { this.mimeType = mimeType; this.gpsCoords = gpsCoords; this.createdTimestamp = createdTimestamp; + this.contentUri = contentUri; imageQuality = BehaviorSubject.createDefault(ImageUtils.IMAGE_WAIT); } @@ -66,6 +69,8 @@ public class UploadItem { return imageQuality.getValue(); } + public Uri getContentUri() { return contentUri; } + public void setImageQuality(final int imageQuality) { this.imageQuality.onNext(imageQuality); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java index cf72fa5d6..1d1b7117f 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java @@ -106,7 +106,8 @@ public class UploadModel { final UploadItem uploadItem = new UploadItem( Uri.parse(uploadableFile.getFilePath()), uploadableFile.getMimeType(context), imageCoordinates, place, fileCreatedDate, - createdTimestampSource); + createdTimestampSource, + uploadableFile.getContentUri()); if (place != null) { uploadItem.getUploadMediaDetails().set(0, new UploadMediaDetail(place)); } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index 15f7df517..9320be156 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -17,8 +17,11 @@ import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.contributions.ChunkInfo import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.ContributionDao +import fr.free.nrw.commons.customselector.database.UploadedStatus +import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.di.ApplicationlessInjection import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.StashUploadState import fr.free.nrw.commons.upload.UploadClient import fr.free.nrw.commons.upload.UploadResult @@ -48,12 +51,18 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : @Inject lateinit var contributionDao: ContributionDao + @Inject + lateinit var uploadedStatusDao: UploadedStatusDao + @Inject lateinit var uploadClient: UploadClient @Inject lateinit var mediaClient: MediaClient + @Inject + lateinit var fileUtilsWrapper: FileUtilsWrapper + private val PROCESSING_UPLOADS_NOTIFICATION_TAG = BuildConfig.APPLICATION_ID + " : upload_tag" private val PROCESSING_UPLOADS_NOTIFICATION_ID = 101 @@ -383,6 +392,20 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) : .blockingGet() contributionFromUpload.dateModified=Date() contributionDao.deleteAndSaveContribution(contribution, contributionFromUpload) + + // Upload success, save to uploaded status. + saveIntoUploadedStatus(contribution) + } + + /** + * Save to uploadedStatusDao. + */ + private fun saveIntoUploadedStatus(contribution: Contribution) { + contribution.contentUri?.let { + val imageSha1 = fileUtilsWrapper.getSHA1(appContext.contentResolver.openInputStream(it)) + val modifiedSha1 = fileUtilsWrapper.getSHA1(fileUtilsWrapper.getFileInputStream(contribution.localUri?.path)) + uploadedStatusDao.insertUploaded(UploadedStatus(imageSha1, modifiedSha1, imageSha1 == modifiedSha1, true)); + } } private fun findUniqueFileName(fileName: String): String {