Shift contributions to use Room DB (#3324)

* Part of #3127
* Added Room Dependency
* Shifted ContributionsDao to use RoomDB

* Save and Fetch contributions via RoomDAO

* Bugfixes, fixed test cases, injected schedulers for ContributionsPresenter

* removed stetho

* Fixed ReviewHelperTest cases

* Fixed test cases in DeleteHelperTest

* Fetch all contributions [TODO add pagination to use this, maybe later in a seperate PR]

* Update Schema false in AppDatabase

* removed parameter from fetchControbutions

* Added logs for fetch contributions

* Fixed test case ContributionsPresenter

* Added an autogenerate primary key, submit save contributions on executor

* fixed getItemAtPosition

* MainActivity Config changes +=orientation

* BugFixes
* Make AppDataBase Singleton
* Set _id as autogenerate primary key [replacing the previously used filename, seems like they are not unique]
* Replace Execxutor Utils with Subscribers on Singles in UploadService
* BugFix, Upload Progress

* Remove un-nescessary null check on contributions in ContributionsListAdapter

* removed ContributionsListFragment [not-implemeted]

* Review suggested changes
* removed un-nescessary null checks
* provide ContributionsDao
* Minor bug fixes

* wip

* delete existing contributions table (from the existing db) on upgrade

* remove un-nescessary null checks in test classes

* shifted media to be a local variable in ReviewHelperTest

* removed captured folder

* Dispose composite disposables in UploadService

* replaced size check with isEmpty ContributionsPresenter

* transform saveContributions to a Completable

* Addressed comments in review
* Typo in Contributions
* ReasonBuilderTest (create media object instead of mocking)
* Use global Gson object instead of creating a new one in Converters

* Provide Gson to Converters from the CommonsApplicationComponent

* use static method instead of field instead of static field to provide GSON in Converters

* Modified gitignore to exclude captures/*
This commit is contained in:
Ashish Kumar 2020-03-10 02:43:20 +05:30 committed by GitHub
parent 642ed51c8c
commit 99c6f5f105
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 557 additions and 1296 deletions

1
.gitignore vendored
View file

@ -38,3 +38,4 @@ app/src/main/jniLibs
#Below removes all the HTML files related to OpenCV documentation. The documentation can be otherwise found at:
#https://docs.opencv.org/3.3.0/
/libraries/opencv/javadoc/
captures/*

View file

@ -102,7 +102,14 @@ dependencies {
//swipe_layout
implementation 'com.daimajia.swipelayout:library:1.2.0@aar'
//Room
def room_version= '2.2.3'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version" // For Kotlin use kapt instead of annotationProcessor
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
implementation "androidx.room:room-rxjava2:$room_version"
testImplementation "androidx.arch.core:core-testing:2.1.0"
}
android {

View file

@ -85,7 +85,7 @@
android:name=".contributions.MainActivity"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:configChanges="screenSize|keyboard" />
android:configChanges="screenSize|keyboard|orientation" />
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings" />
@ -157,18 +157,6 @@
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".contributions.ContributionsSyncService"
android:exported="true"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/contributions_sync_adapter" />
</service>
<service
android:name="org.acra.sender.SenderService"
@ -184,12 +172,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name=".contributions.ContributionsContentProvider"
android:authorities="${applicationId}.contributions.contentprovider"
android:exported="false"
android:label="@string/provider_contributions"
android:syncable="true" />
<provider
android:name=".category.CategoryContentProvider"

View file

@ -6,6 +6,7 @@ import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.os.Process;
import android.util.Log;
@ -44,8 +45,8 @@ import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler;
import fr.free.nrw.commons.concurrency.ThreadPoolService;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.logging.FileLoggingTree;
@ -60,6 +61,7 @@ import io.reactivex.schedulers.Schedulers;
import okhttp3.OkHttpClient;
import timber.log.Timber;
import static fr.free.nrw.commons.data.DBOpenHelper.CONTRIBUTIONS_TABLE;
import static org.acra.ReportField.ANDROID_VERSION;
import static org.acra.ReportField.APP_VERSION_CODE;
import static org.acra.ReportField.APP_VERSION_NAME;
@ -126,6 +128,9 @@ public class CommonsApplication extends Application {
return languageLookUpTable;
}
@Inject
AppDatabase appDatabase;
/**
* Used to declare and initialize various components and dependencies
*/
@ -306,11 +311,13 @@ public class CommonsApplication extends Application {
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
CategoryDao.Table.onDelete(db);
ContributionDao.Table.onDelete(db);
dbOpenHelper.deleteTable(db,CONTRIBUTIONS_TABLE);//Delete the contributions table in the existing db on older versions
appDatabase.getContributionDao().deleteAll();
BookmarkPicturesDao.Table.onDelete(db);
BookmarkLocationsDao.Table.onDelete(db);
}
/**
* Interface used to get log-out events
*/

View file

@ -6,6 +6,8 @@ import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import org.apache.commons.lang3.StringUtils;
import org.wikipedia.dataclient.mwapi.MwQueryPage;
@ -26,6 +28,7 @@ import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.utils.MediaDataExtractorUtil;
@Entity
public class Media implements Parcelable {
public static final Media EMPTY = new Media("");
@ -42,25 +45,25 @@ public class Media implements Parcelable {
};
// Primary metadata fields
protected Uri localUri;
private String thumbUrl;
protected String imageUrl;
protected String filename;
protected String description; // monolingual description on input...
protected String discussion;
protected long dataLength;
protected Date dateCreated;
protected @Nullable Date dateUploaded;
protected int width;
protected int height;
protected String license;
protected String licenseUrl;
protected String creator;
protected ArrayList<String> categories; // as loaded at runtime?
protected boolean requestedDeletion;
private Map<String, String> descriptions; // multilingual descriptions as loaded
private HashMap<String, Object> tags = new HashMap<>();
private @Nullable LatLng coordinates;
public Uri localUri;
public String thumbUrl;
public String imageUrl;
public String filename;
public String description; // monolingual description on input...
public String discussion;
long dataLength;
public Date dateCreated;
@Nullable public Date dateUploaded;
public int width;
public int height;
public String license;
public String licenseUrl;
public String creator;
public ArrayList<String> categories; // as loaded at runtime?
public boolean requestedDeletion;
public HashMap<String, String> descriptions; // multilingual descriptions as loaded
public HashMap<String, String> tags = new HashMap<>();
@Nullable public LatLng coordinates;
/**
* Provides local constructor
@ -118,7 +121,7 @@ public class Media implements Parcelable {
dateCreated = (Date) in.readSerializable();
dateUploaded = (Date) in.readSerializable();
creator = in.readString();
tags = (HashMap<String, Object>) in.readSerializable();
tags = (HashMap<String, String>) in.readSerializable();
width = in.readInt();
height = in.readInt();
license = in.readString();
@ -218,7 +221,7 @@ public class Media implements Parcelable {
* @param key Media key
* @param value Media value
*/
public void setTag(String key, Object value) {
public void setTag(String key, String value) {
tags.put(key, value);
}

View file

@ -6,6 +6,8 @@ import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.StringDef;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
import org.apache.commons.lang3.StringUtils;
@ -21,6 +23,7 @@ import fr.free.nrw.commons.utils.ConfigUtils;
import static java.lang.annotation.RetentionPolicy.SOURCE;
@Entity(tableName = "contribution")
public class Contribution extends Media {
//{{According to Exif data|2009-01-09}}
@ -54,17 +57,19 @@ public class Contribution extends Media {
public static final String SOURCE_CAMERA = "camera";
public static final String SOURCE_GALLERY = "gallery";
public static final String SOURCE_EXTERNAL = "external";
private Uri contentUri;
private String source;
private String editSummary;
private int state;
private long transferred;
private String decimalCoords;
private boolean isMultiple;
private String wikiDataEntityId;
private Uri contentProviderUri;
private String dateCreatedSource;
@PrimaryKey (autoGenerate = true)
@NonNull
public long _id;
public Uri contentUri;
public String source;
public String editSummary;
public int state;
public long transferred;
public String decimalCoords;
public boolean isMultiple;
public String wikiDataEntityId;
public Uri contentProviderUri;
public String dateCreatedSource;
public Contribution(Uri contentUri, String filename, Uri localUri, String imageUrl, Date dateCreated,
int state, long dataLength, Date dateUploaded, long transferred,

View file

@ -1,331 +1,55 @@
package fr.free.nrw.commons.contributions;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.net.Uri;
import android.os.RemoteException;
import android.text.TextUtils;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Transaction;
import androidx.annotation.Nullable;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.Date;
@Dao
public abstract class ContributionDao {
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
@Query("SELECT * FROM contribution order by dateUploaded DESC")
abstract LiveData<List<Contribution>> fetchContributions();
import fr.free.nrw.commons.settings.Prefs;
import timber.log.Timber;
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract Single<Long> save(Contribution contribution);
import static fr.free.nrw.commons.contributions.ContributionDao.Table.ALL_FIELDS;
import static fr.free.nrw.commons.contributions.ContributionDao.Table.COLUMN_WIKI_DATA_ENTITY_ID;
import static fr.free.nrw.commons.contributions.ContributionsContentProvider.BASE_URI;
import static fr.free.nrw.commons.contributions.ContributionsContentProvider.uriForId;
public class ContributionDao {
/*
This sorts in the following order:
Currently Uploading
Failed (Sorted in ascending order of time added - FIFO)
Queued to Upload (Sorted in ascending order of time added - FIFO)
Completed (Sorted in descending order of time added)
This is why Contribution.STATE_COMPLETED is -1.
*/
static final String CONTRIBUTION_SORT = Table.COLUMN_STATE + " DESC, "
+ Table.COLUMN_UPLOADED + " DESC , ("
+ Table.COLUMN_TIMESTAMP + " * "
+ Table.COLUMN_STATE + ")";
private final Provider<ContentProviderClient> clientProvider;
@Inject
public ContributionDao(@Named("contribution") Provider<ContentProviderClient> clientProvider) {
this.clientProvider = clientProvider;
public Completable deleteAllAndSave(List<Contribution> contributions){
return Completable.fromAction(() -> deleteAllAndSaveTransaction(contributions));
}
Cursor loadAllContributions() {
ContentProviderClient db = clientProvider.get();
try {
return db.query(BASE_URI, ALL_FIELDS, "", null, CONTRIBUTION_SORT);
} catch (RemoteException e) {
return null;
} finally {
db.release();
}
@Transaction
public void deleteAllAndSaveTransaction(List<Contribution> contributions){
deleteAll(Contribution.STATE_COMPLETED);
save(contributions);
}
public void save(Contribution contribution) {
ContentProviderClient db = clientProvider.get();
try {
if (contribution.getContentUri() == null) {
contribution.setContentUri(db.insert(BASE_URI, toContentValues(contribution)));
} else {
db.update(contribution.getContentUri(), toContentValues(contribution), null, null);
}
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
@Insert
public abstract void save(List<Contribution> contribution);
public void delete(Contribution contribution) {
ContentProviderClient db = clientProvider.get();
try {
if (contribution.getContentUri() == null) {
// noooo
throw new RuntimeException("tried to delete item with no content URI");
} else {
db.delete(contribution.getContentUri(), null, null);
}
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
@Delete
public abstract Single<Integer> delete(Contribution contribution);
ContentValues toContentValues(Contribution contribution) {
ContentValues cv = new ContentValues();
cv.put(Table.COLUMN_FILENAME, contribution.getFilename());
if (contribution.getLocalUri() != null) {
cv.put(Table.COLUMN_LOCAL_URI, contribution.getLocalUri().toString());
}
if (contribution.getImageUrl() != null) {
cv.put(Table.COLUMN_IMAGE_URL, contribution.getImageUrl());
}
if (contribution.getDateUploaded() != null) {
cv.put(Table.COLUMN_UPLOADED, contribution.getDateUploaded().getTime());
}
cv.put(Table.COLUMN_LENGTH, contribution.getDataLength());
//This was always meant to store the date created..If somehow date created is not fetched while actually saving the contribution, lets saveValue today's date
cv.put(Table.COLUMN_TIMESTAMP, contribution.getDateCreated()==null?System.currentTimeMillis():contribution.getDateCreated().getTime());
cv.put(Table.COLUMN_STATE, contribution.getState());
cv.put(Table.COLUMN_TRANSFERRED, contribution.getTransferred());
cv.put(Table.COLUMN_SOURCE, contribution.getSource());
cv.put(Table.COLUMN_DESCRIPTION, contribution.getDescription());
cv.put(Table.COLUMN_CREATOR, contribution.getCreator());
cv.put(Table.COLUMN_MULTIPLE, contribution.getMultiple() ? 1 : 0);
cv.put(Table.COLUMN_WIDTH, contribution.getWidth());
cv.put(Table.COLUMN_HEIGHT, contribution.getHeight());
cv.put(Table.COLUMN_LICENSE, contribution.getLicense());
cv.put(Table.COLUMN_WIKI_DATA_ENTITY_ID, contribution.getWikiDataEntityId());
return cv;
}
@Query("SELECT * from contribution WHERE contentProviderUri=:uri")
public abstract List<Contribution> getContributionWithUri(String uri);
public Contribution fromCursor(Cursor cursor) {
// Hardcoding column positions!
//Check that cursor has a value to avoid CursorIndexOutOfBoundsException
if (cursor.getCount() > 0) {
int index;
if (cursor.getColumnIndex(Table.COLUMN_LICENSE) == -1){
index = 15;
} else {
index = cursor.getColumnIndex(Table.COLUMN_LICENSE);
}
Contribution contribution = new Contribution(
uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_FILENAME)),
parseUri(cursor.getString(cursor.getColumnIndex(Table.COLUMN_LOCAL_URI))),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_IMAGE_URL)),
parseTimestamp(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_TIMESTAMP))),
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_STATE)),
cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LENGTH)),
parseTimestamp(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_UPLOADED))),
cursor.getLong(cursor.getColumnIndex(Table.COLUMN_TRANSFERRED)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_SOURCE)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_CREATOR)),
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_MULTIPLE)) == 1,
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_WIDTH)),
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_HEIGHT)),
cursor.getString(index)
);
String wikidataEntityId = cursor.getString(cursor.getColumnIndex(COLUMN_WIKI_DATA_ENTITY_ID));
if (!StringUtils.isBlank(wikidataEntityId)) {
contribution.setWikiDataEntityId(wikidataEntityId);
}
return contribution;
}
return null;
}
@Nullable
private static Date parseTimestamp(long timestamp) {
return timestamp == 0 ? null : new Date(timestamp);
}
@Nullable
private static Uri parseUri(String uriString) {
return TextUtils.isEmpty(uriString) ? null : Uri.parse(uriString);
}
public static class Table {
public static final String TABLE_NAME = "contributions";
public static final String COLUMN_ID = "_id";
public static final String COLUMN_FILENAME = "filename";
public static final String COLUMN_LOCAL_URI = "local_uri";
public static final String COLUMN_IMAGE_URL = "image_url";
public static final String COLUMN_TIMESTAMP = "timestamp";
public static final String COLUMN_STATE = "state";
public static final String COLUMN_LENGTH = "length";
public static final String COLUMN_UPLOADED = "uploaded";
public static final String COLUMN_TRANSFERRED = "transferred"; // Currently transferred number of bytes
public static final String COLUMN_SOURCE = "source";
public static final String COLUMN_DESCRIPTION = "description";
public static final String COLUMN_CREATOR = "creator"; // Initial uploader
public static final String COLUMN_MULTIPLE = "multiple";
public static final String COLUMN_WIDTH = "width";
public static final String COLUMN_HEIGHT = "height";
public static final String COLUMN_LICENSE = "license";
public static final String COLUMN_WIKI_DATA_ENTITY_ID = "wikidataEntityID";
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
public static final String[] ALL_FIELDS = {
COLUMN_ID,
COLUMN_FILENAME,
COLUMN_LOCAL_URI,
COLUMN_IMAGE_URL,
COLUMN_TIMESTAMP,
COLUMN_STATE,
COLUMN_LENGTH,
COLUMN_UPLOADED,
COLUMN_TRANSFERRED,
COLUMN_SOURCE,
COLUMN_DESCRIPTION,
COLUMN_CREATOR,
COLUMN_MULTIPLE,
COLUMN_WIDTH,
COLUMN_HEIGHT,
COLUMN_LICENSE,
COLUMN_WIKI_DATA_ENTITY_ID
};
public static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
public static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ "_id INTEGER PRIMARY KEY,"
+ "filename STRING,"
+ "local_uri STRING,"
+ "image_url STRING,"
+ "uploaded INTEGER,"
+ "timestamp INTEGER,"
+ "state INTEGER,"
+ "length INTEGER,"
+ "transferred INTEGER,"
+ "source STRING,"
+ "description STRING,"
+ "creator STRING,"
+ "multiple INTEGER,"
+ "width INTEGER,"
+ "height INTEGER,"
+ "LICENSE STRING,"
+ "wikidataEntityID STRING"
+ ");";
// Upgrade from version 1 ->
static final String ADD_CREATOR_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN creator STRING;";
static final String ADD_DESCRIPTION_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN description STRING;";
// Upgrade from version 2 ->
static final String ADD_MULTIPLE_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN multiple INTEGER;";
static final String SET_DEFAULT_MULTIPLE = "UPDATE " + TABLE_NAME + " SET multiple = 0";
// Upgrade from version 5 ->
static final String ADD_WIDTH_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN width INTEGER;";
static final String SET_DEFAULT_WIDTH = "UPDATE " + TABLE_NAME + " SET width = 0";
static final String ADD_HEIGHT_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN height INTEGER;";
static final String SET_DEFAULT_HEIGHT = "UPDATE " + TABLE_NAME + " SET height = 0";
static final String ADD_LICENSE_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN license STRING;";
static final String SET_DEFAULT_LICENSE = "UPDATE " + TABLE_NAME + " SET license='" + Prefs.Licenses.CC_BY_SA_3 + "';";
// Upgrade from version 8 ->
static final String ADD_WIKI_DATA_ENTITY_ID_FIELD = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN wikidataEntityID STRING;";
public static void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
}
public static void onDelete(SQLiteDatabase db) {
db.execSQL(DROP_TABLE_STATEMENT);
onCreate(db);
}
public static void onUpdate(SQLiteDatabase db, int from, int to) {
if (from == to) {
return;
}
//Considering the crashes we have been facing recently, lets blindly add this column to any table which has ever existed
runQuery(db,ADD_WIKI_DATA_ENTITY_ID_FIELD);
if (from == 1) {
runQuery(db,ADD_DESCRIPTION_FIELD);
runQuery(db,ADD_CREATOR_FIELD);
from++;
onUpdate(db, from, to);
return;
}
if (from == 2) {
runQuery(db, ADD_MULTIPLE_FIELD);
runQuery(db, SET_DEFAULT_MULTIPLE);
from++;
onUpdate(db, from, to);
return;
}
if (from == 3) {
// Do nothing
from++;
onUpdate(db, from, to);
return;
}
if (from == 4) {
// Do nothing -- added Category
from++;
onUpdate(db, from, to);
return;
}
if (from == 5) {
// Added width and height fields
runQuery(db, ADD_WIDTH_FIELD);
runQuery(db, SET_DEFAULT_WIDTH);
runQuery(db, ADD_HEIGHT_FIELD);
runQuery(db, SET_DEFAULT_HEIGHT);
runQuery(db, ADD_LICENSE_FIELD);
runQuery(db, SET_DEFAULT_LICENSE);
from++;
onUpdate(db, from, to);
return;
}
if (from > 5) {
// Added place field
from=to;
onUpdate(db, from, to);
return;
}
}
/**
* perform the db.execSQl with handled exceptions
*/
private static void runQuery(SQLiteDatabase db, String query) {
try {
db.execSQL(query);
} catch (SQLiteException e) {
Timber.e("Exception performing query: " + query + " message: " + e.getMessage());
}
}
}
@Query("SELECT * from contribution WHERE filename=:fileName")
public abstract List<Contribution> getContributionWithTitle(String fileName);
@Query("UPDATE contribution SET state=:state WHERE state in (:toUpdateStates)")
public abstract Single<Integer> updateStates(int state, int[] toUpdateStates);
@Query("Delete FROM contribution")
public abstract void deleteAll();
@Query("Delete FROM contribution WHERE state = :state")
public abstract void deleteAll(int state);
}

View file

@ -1,186 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import javax.inject.Inject;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
import timber.log.Timber;
import static android.content.UriMatcher.NO_MATCH;
import static fr.free.nrw.commons.contributions.ContributionDao.Table.ALL_FIELDS;
import static fr.free.nrw.commons.contributions.ContributionDao.Table.TABLE_NAME;
public class ContributionsContentProvider extends CommonsDaggerContentProvider {
private static final int CONTRIBUTIONS = 1;
private static final int CONTRIBUTIONS_ID = 2;
private static final String BASE_PATH = "contributions";
private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CONTRIBUTION_AUTHORITY + "/" + BASE_PATH);
static {
uriMatcher.addURI(BuildConfig.CONTRIBUTION_AUTHORITY, BASE_PATH, CONTRIBUTIONS);
uriMatcher.addURI(BuildConfig.CONTRIBUTION_AUTHORITY, BASE_PATH + "/#", CONTRIBUTIONS_ID);
}
public static Uri uriForId(int id) {
return Uri.parse(BASE_URI.toString() + "/" + id);
}
@Inject DBOpenHelper dbOpenHelper;
@SuppressWarnings("ConstantConditions")
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(TABLE_NAME);
int uriType = uriMatcher.match(uri);
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor;
switch (uriType) {
case CONTRIBUTIONS:
cursor = queryBuilder.query(db, projection, selection, selectionArgs,
null, null, sortOrder);
break;
case CONTRIBUTIONS_ID:
cursor = queryBuilder.query(db,
ALL_FIELDS,
"_id = ?",
new String[]{uri.getLastPathSegment()},
null,
null,
sortOrder
);
break;
default:
throw new IllegalArgumentException("Unknown URI" + uri);
}
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Override
public String getType(@NonNull Uri uri) {
return null;
}
@SuppressWarnings("ConstantConditions")
@Override
public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
long id;
switch (uriType) {
case CONTRIBUTIONS:
id = sqlDB.insert(TABLE_NAME, null, contentValues);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return Uri.parse(BASE_URI + "/" + id);
}
@SuppressWarnings("ConstantConditions")
@Override
public int delete(@NonNull Uri uri, String s, String[] strings) {
int rows;
int uriType = uriMatcher.match(uri);
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
switch (uriType) {
case CONTRIBUTIONS_ID:
Timber.d("Deleting contribution id %s", uri.getLastPathSegment());
rows = db.delete(TABLE_NAME,
"_id = ?",
new String[]{uri.getLastPathSegment()}
);
break;
default:
throw new IllegalArgumentException("Unknown URI" + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return rows;
}
@SuppressWarnings("ConstantConditions")
@Override
public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
Timber.d("Hello, bulk insert! (ContributionsContentProvider)");
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
sqlDB.beginTransaction();
switch (uriType) {
case CONTRIBUTIONS:
for (ContentValues value : values) {
Timber.d("Inserting! %s", value);
sqlDB.insert(TABLE_NAME, null, value);
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
sqlDB.setTransactionSuccessful();
sqlDB.endTransaction();
getContext().getContentResolver().notifyChange(uri, null);
return values.length;
}
@SuppressWarnings("ConstantConditions")
@Override
public int update(@NonNull Uri uri, ContentValues contentValues, String selection, String[] selectionArgs) {
/*
SQL Injection warnings: First, note that we're not exposing this to the outside world (exported="false")
Even then, we should make sure to sanitize all user input appropriately.
Input that passes through ContentValues should be fine. So only issues are those that pass
in via concatenating.
In here, the only concat created argument is for id. It is cast to an int, and will
error out otherwise.
*/
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
int rowsUpdated;
switch (uriType) {
case CONTRIBUTIONS:
rowsUpdated = sqlDB.update(TABLE_NAME, contentValues, selection, selectionArgs);
break;
case CONTRIBUTIONS_ID:
int id = Integer.valueOf(uri.getLastPathSegment());
if (TextUtils.isEmpty(selection)) {
rowsUpdated = sqlDB.update(TABLE_NAME,
contentValues,
ContributionDao.Table.COLUMN_ID + " = ?",
new String[]{String.valueOf(id)});
} else {
throw new IllegalArgumentException(
"Parameter `selection` should be empty when updating an ID");
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsUpdated;
}
}

View file

@ -1,8 +1,6 @@
package fr.free.nrw.commons.contributions;
import android.database.Cursor;
import androidx.loader.app.LoaderManager;
import java.util.List;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.Media;
@ -22,13 +20,13 @@ public class ContributionsContract {
void setUploadCount(int count);
void onDataSetChanged();
void showContributions(List<Contribution> contributionList);
void showMessage(String localizedMessage);
}
public interface UserActionListener extends BasePresenter<ContributionsContract.View>,
LoaderManager.LoaderCallbacks<Cursor> {
Contribution getContributionsFromCursor(Cursor cursor);
public interface UserActionListener extends BasePresenter<ContributionsContract.View> {
Contribution getContributionsWithTitle(String uri);
void deleteUpload(Contribution contribution);

View file

@ -20,6 +20,8 @@ import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
import androidx.fragment.app.FragmentTransaction;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
@ -71,7 +73,6 @@ public class ContributionsFragment
LocationUpdateListener,
ICampaignsView, ContributionsContract.View {
@Inject @Named("default_preferences") JsonKvStore store;
@Inject ContributionDao contributionDao;
@Inject NearbyController nearbyController;
@Inject OkHttpJsonApiClient okHttpJsonApiClient;
@Inject CampaignsPresenter presenter;
@ -118,11 +119,11 @@ public class ContributionsFragment
};
private boolean shouldShowMediaDetailsFragment;
private int numberOfContributions;
private boolean isAuthCookieAcquired;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
@Nullable
@ -132,6 +133,7 @@ public class ContributionsFragment
ButterKnife.bind(this, view);
presenter.onAttachView(this);
contributionsPresenter.onAttachView(this);
contributionsPresenter.setLifeCycleOwner(this.getViewLifecycleOwner());
campaignView.setVisibility(View.GONE);
checkBoxView = View.inflate(getActivity(), R.layout.nearby_permission_dialog, null);
checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again);
@ -210,20 +212,10 @@ public class ContributionsFragment
showDetail(position);
}
@Override
public int getNumberOfContributions() {
return numberOfContributions;
}
@Override
public Contribution getContributionForPosition(int position) {
return (Contribution) contributionsPresenter.getItemAtPosition(position);
}
@Override
public int findItemPositionWithId(String id) {
return contributionsPresenter.getChildPositionWithId(id);
}
});
if(null==mediaDetailPagerFragment){
@ -306,11 +298,10 @@ public class ContributionsFragment
*/
void onAuthCookieAcquired() {
// Since we call onAuthCookieAcquired method from onAttach, isAdded is still false. So don't use it
isAuthCookieAcquired=true;
if (getActivity() != null) { // If fragment is attached to parent activity
getActivity().bindService(getUploadServiceIntent(), uploadServiceConnection, Context.BIND_AUTO_CREATE);
isUploadServiceConnected = true;
getActivity().getSupportLoaderManager().initLoader(0, null, contributionsPresenter);
}
}
@ -336,7 +327,7 @@ public class ContributionsFragment
@Override
public void refreshSource() {
getActivity().getSupportLoaderManager().restartLoader(0, null, contributionsPresenter);
contributionsPresenter.fetchContributions();
}
@Override
@ -411,6 +402,10 @@ public class ContributionsFragment
}
fetchCampaigns();
if(isAuthCookieAcquired){
contributionsPresenter.fetchContributions();
}
}
private void checkPermissionsAndShowNearbyCardView() {
@ -578,9 +573,8 @@ public class ContributionsFragment
}
@Override
public void onDataSetChanged() {
contributionsListFragment.onDataSetChanged();
mediaDetailPagerFragment.onDataSetChanged();
public void showContributions(List<Contribution> contributionList) {
contributionsListFragment.setContributions(contributionList);
}
/**

View file

@ -6,6 +6,9 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.model.DisplayableContribution;
@ -15,9 +18,11 @@ import fr.free.nrw.commons.contributions.model.DisplayableContribution;
public class ContributionsListAdapter extends RecyclerView.Adapter<ContributionViewHolder> {
private Callback callback;
private List<Contribution> contributions;
public ContributionsListAdapter(Callback callback) {
this.callback = callback;
contributions=new ArrayList<>();
}
/**
@ -35,7 +40,7 @@ public class ContributionsListAdapter extends RecyclerView.Adapter<ContributionV
@Override
public void onBindViewHolder(@NonNull ContributionViewHolder holder, int position) {
final Contribution contribution = callback.getContributionForPosition(position);
final Contribution contribution = contributions.get(position);
DisplayableContribution displayableContribution = new DisplayableContribution(contribution,
position);
holder.init(position, displayableContribution);
@ -43,7 +48,15 @@ public class ContributionsListAdapter extends RecyclerView.Adapter<ContributionV
@Override
public int getItemCount() {
return callback.getNumberOfContributions();
return contributions.size();
}
public void setContributions(List<Contribution> contributionList) {
if(null!=contributionList) {
this.contributions.clear();
this.contributions.addAll(contributionList);
notifyDataSetChanged();
}
}
public interface Callback {
@ -54,10 +67,6 @@ public class ContributionsListAdapter extends RecyclerView.Adapter<ContributionV
void openMediaDetail(int contribution);
int getNumberOfContributions();
Contribution getContributionForPosition(int position);
int findItemPositionWithId(String lastVisibleItemID);
}
}

View file

@ -20,6 +20,9 @@ import androidx.recyclerview.widget.RecyclerView.LayoutManager;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
@ -72,6 +75,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
private String lastVisibleItemID;
private int SPAN_COUNT=3;
private List<Contribution> contributions=new ArrayList<>();
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_contributions_list, container, false);
@ -104,6 +108,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
}
rvContributionsList.setAdapter(adapter);
adapter.setContributions(contributions);
}
@Override
@ -178,16 +183,10 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
}
public void onDataSetChanged() {
if (null != adapter) {
adapter.notifyDataSetChanged();
//Restoring last visible item position in cases of orientation change
if (null != lastVisibleItemID) {
int itemPositionWithId = callback.findItemPositionWithId(lastVisibleItemID);
rvContributionsList.scrollToPosition(itemPositionWithId);
lastVisibleItemID = null;//Reset the lastVisibleItemID once we have used it
}
}
public void setContributions(List<Contribution> contributionList) {
this.contributions.clear();
this.contributions.addAll(contributionList);
adapter.setContributions(contributions);
}
public interface SourceRefresher {
@ -228,7 +227,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment {
private String findIdOfItemWithPosition(int position) {
Contribution contributionForPosition = callback.getContributionForPosition(position);
if (null != contributionForPosition) {
return contributionForPosition.getContentUri().getLastPathSegment();
return contributionForPosition.getFilename();
}
return null;
}

View file

@ -1,18 +1,22 @@
package fr.free.nrw.commons.contributions;
import android.database.Cursor;
import androidx.lifecycle.LiveData;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Completable;
import io.reactivex.Single;
/**
* The LocalDataSource class for Contributions
*/
class ContributionsLocalDataSource {
private final ContributionDao contributionsDao;
private final ContributionDao contributionDao;
private final JsonKvStore defaultKVStore;
@Inject
@ -20,30 +24,54 @@ class ContributionsLocalDataSource {
@Named("default_preferences") JsonKvStore defaultKVStore,
ContributionDao contributionDao) {
this.defaultKVStore = defaultKVStore;
this.contributionsDao = contributionDao;
this.contributionDao = contributionDao;
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public int get(String key) {
return defaultKVStore.getInt(key);
public String getString(String key) {
return defaultKVStore.getString(key);
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public long getLong(String key) {
return defaultKVStore.getLong(key);
}
/**
* Get contribution object from cursor
* @param cursor
* @param uri
* @return
*/
public Contribution getContributionFromCursor(Cursor cursor) {
return contributionsDao.fromCursor(cursor);
public Contribution getContributionWithFileName(String uri) {
List<Contribution> contributionWithUri = contributionDao.getContributionWithTitle(uri);
if(!contributionWithUri.isEmpty()){
return contributionWithUri.get(0);
}
return null;
}
/**
* Remove a contribution from the contributions table
* @param contribution
* @return
*/
public void deleteContribution(Contribution contribution) {
contributionsDao.delete(contribution);
public Single<Integer> deleteContribution(Contribution contribution) {
return contributionDao.delete(contribution);
}
public LiveData<List<Contribution>> getContributions() {
return contributionDao.fetchContributions();
}
public Completable saveContributions(List<Contribution> contributions) {
return contributionDao.deleteAllAndSave(contributions);
}
public void set(String key, long value) {
defaultKVStore.putLong(key,value);
}
}

View file

@ -3,104 +3,156 @@ package fr.free.nrw.commons.contributions;
import android.content.Context;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import fr.free.nrw.commons.mwapi.UserClient;
import fr.free.nrw.commons.utils.ExecutorUtils;
import fr.free.nrw.commons.utils.NetworkUtils;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.SingleObserver;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
import static fr.free.nrw.commons.contributions.ContributionDao.Table.ALL_FIELDS;
import static fr.free.nrw.commons.contributions.ContributionsContentProvider.BASE_URI;
import static fr.free.nrw.commons.settings.Prefs.UPLOADS_SHOWING;
import static fr.free.nrw.commons.contributions.Contribution.STATE_COMPLETED;
/**
* The presenter class for Contributions
*/
public class ContributionsPresenter extends DataSetObserver implements UserActionListener {
public class ContributionsPresenter implements UserActionListener {
private final ContributionsRepository repository;
private final Scheduler mainThreadScheduler;
private final Scheduler ioThreadScheduler;
private CompositeDisposable compositeDisposable;
private ContributionsContract.View view;
private Cursor cursor;
private List<Contribution> contributionList=new ArrayList<>();
@Inject
Context context;
@Inject
ContributionsPresenter(ContributionsRepository repository) {
UserClient userClient;
@Inject
AppDatabase appDatabase;
@Inject
SessionManager sessionManager;
private LifecycleOwner lifeCycleOwner;
@Inject
ContributionsPresenter(ContributionsRepository repository, @Named(CommonsApplicationModule.MAIN_THREAD) Scheduler mainThreadScheduler,@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) {
this.repository = repository;
this.mainThreadScheduler=mainThreadScheduler;
this.ioThreadScheduler=ioThreadScheduler;
}
private String user;
@Override
public void onAttachView(ContributionsContract.View view) {
this.view = view;
if (null != cursor) {
try {
cursor.registerDataSetObserver(this);
} catch (IllegalStateException e) {//Cursor might be already registered
Timber.d(e);
compositeDisposable=new CompositeDisposable();
}
public void setLifeCycleOwner(LifecycleOwner lifeCycleOwner){
this.lifeCycleOwner=lifeCycleOwner;
}
public void fetchContributions() {
Timber.d("fetch Contributions");
LiveData<List<Contribution>> liveDataContributions = repository.fetchContributions();
if(null!=lifeCycleOwner) {
liveDataContributions.observe(lifeCycleOwner, this::showContributions);
}
if (NetworkUtils.isInternetConnectionEstablished(CommonsApplication.getInstance()) && shouldFetchContributions()) {
Timber.d("fetching contributions: ");
view.showProgress(true);
this.user = sessionManager.getUserName();
view.showContributions(Collections.emptyList());
compositeDisposable.add(userClient.logEvents(user)
.subscribeOn(ioThreadScheduler)
.observeOn(mainThreadScheduler)
.doOnNext(mwQueryLogEvent -> Timber.d("Received image %s", mwQueryLogEvent.title()))
.filter(mwQueryLogEvent -> !mwQueryLogEvent.isDeleted()).doOnNext(mwQueryLogEvent -> Timber.d("Image %s passed filters", mwQueryLogEvent.title()))
.map(image -> {
Contribution contribution = new Contribution(null, null, image.title(),
"", -1, image.date(), image.date(), user,
"", "", STATE_COMPLETED);
return contribution;
})
.toList()
.subscribe(this::saveContributionsToDB, error -> {
Timber.e("Failed to fetch contributions: %s", error.getMessage());
}));
}
}
private void showContributions(@NonNull List<Contribution> contributions) {
view.showProgress(false);
if (contributions.isEmpty()) {
view.showWelcomeTip(true);
view.showNoContributionsUI(true);
} else {
view.showWelcomeTip(false);
view.showNoContributionsUI(false);
view.setUploadCount(contributions.size());
view.showContributions(contributions);
this.contributionList.clear();
this.contributionList.addAll(contributions);
}
}
private void saveContributionsToDB(List<Contribution> contributions) {
Timber.e("Fetched: "+contributions.size()+" contributions "+" saving to db");
repository.save(contributions).subscribeOn(ioThreadScheduler).subscribe();
repository.set("last_fetch_timestamp",System.currentTimeMillis());
}
private boolean shouldFetchContributions() {
long lastFetchTimestamp = repository.getLong("last_fetch_timestamp");
Timber.d("last fetch timestamp: %s", lastFetchTimestamp);
if(lastFetchTimestamp!=0){
return System.currentTimeMillis()-lastFetchTimestamp>15*60*100;
}
Timber.d("should fetch contributions: %s", true);
return true;
}
@Override
public void onDetachView() {
this.view = null;
if (null != cursor) {
try {
cursor.unregisterDataSetObserver(this);
} catch (Exception e) {//Cursor might not be already registered
Timber.d(e);
}
}
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) {
int preferredNumberOfUploads = repository.get(UPLOADS_SHOWING);
return new CursorLoader(context, BASE_URI,
ALL_FIELDS, "", null,
ContributionDao.CONTRIBUTION_SORT + "LIMIT "
+ (preferredNumberOfUploads>0?preferredNumberOfUploads:100));
compositeDisposable.clear();
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
view.showProgress(false);
if (null != cursor && cursor.getCount() > 0) {
view.showWelcomeTip(false);
view.showNoContributionsUI(false);
view.setUploadCount(cursor.getCount());
} else {
view.showWelcomeTip(true);
view.showNoContributionsUI(true);
}
swapCursor(cursor);
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
this.cursor = null;
//On LoadFinished is not guaranteed to be called
view.showProgress(false);
view.showWelcomeTip(true);
view.showNoContributionsUI(true);
swapCursor(null);
}
/**
* Get contribution from the repository
*/
@Override
public Contribution getContributionsFromCursor(Cursor cursor) {
return repository.getContributionFromCursor(cursor);
public Contribution getContributionsWithTitle(String title) {
return repository.getContributionWithFileName(title);
}
/**
@ -109,75 +161,23 @@ public class ContributionsPresenter extends DataSetObserver implements UserActio
*/
@Override
public void deleteUpload(Contribution contribution) {
repository.deleteContributionFromDB(contribution);
compositeDisposable.add(repository.deleteContributionFromDB(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
/**
* Returns a contribution at the specified cursor position
*
* @param i
* @return
*/
@Nullable
@Override
public Media getItemAtPosition(int i) {
if (null != cursor && cursor.moveToPosition(i)) {
return getContributionsFromCursor(cursor);
}
if (i == -1 || contributionList.size() < i+1) {
return null;
}
/**
* Get contribution position with id
*/
public int getChildPositionWithId(String id) {
int position = 0;
cursor.moveToFirst();
while (null != cursor && cursor.moveToNext()) {
if (getContributionsFromCursor(cursor).getContentUri().getLastPathSegment()
.equals(id)) {
position = cursor.getPosition();
break;
}
}
return position;
}
@Override
public void onChanged() {
super.onChanged();
view.onDataSetChanged();
}
@Override
public void onInvalidated() {
super.onInvalidated();
//Not letting the view know of this as of now, TODO discuss how to handle this and maybe show a proper ui for this
}
/**
* Swap in a new Cursor, returning the old Cursor. The returned old Cursor is <em>not</em>
* closed.
*
* @param newCursor The new cursor to be used.
* @return Returns the previously set Cursor, or null if there was not one. If the given new
* Cursor is the same instance is the previously set Cursor, null is also returned.
*/
private void swapCursor(Cursor newCursor) {
try {
if (newCursor == cursor) {
return;
}
Cursor oldCursor = cursor;
if (oldCursor != null) {
oldCursor.unregisterDataSetObserver(this);
}
cursor = newCursor;
if (newCursor != null) {
newCursor.registerDataSetObserver(this);
}
view.onDataSetChanged();
} catch (IllegalStateException e) {//Cursor might [not] be already registered/unregistered
Timber.e(e);
}
return contributionList.get(i);
}
}

View file

@ -1,9 +1,14 @@
package fr.free.nrw.commons.contributions;
import android.database.Cursor;
import androidx.lifecycle.LiveData;
import java.util.List;
import javax.inject.Inject;
import io.reactivex.Completable;
import io.reactivex.Single;
/**
* The repository class for contributions
*/
@ -19,25 +24,41 @@ public class ContributionsRepository {
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public int get(String uploadsShowing) {
return localDataSource.get(uploadsShowing);
}
/**
* Get contribution object from cursor from LocalDataSource
* @param cursor
* @return
*/
public Contribution getContributionFromCursor(Cursor cursor) {
return localDataSource.getContributionFromCursor(cursor);
public String getString(String key) {
return localDataSource.getString(key);
}
/**
* Deletes a failed upload from DB
* @param contribution
* @return
*/
public void deleteContributionFromDB(Contribution contribution) {
localDataSource.deleteContribution(contribution);
public Single<Integer> deleteContributionFromDB(Contribution contribution) {
return localDataSource.deleteContribution(contribution);
}
/**
* Get contribution object with title
* @param fileName
* @return
*/
public Contribution getContributionWithFileName(String fileName) {
return localDataSource.getContributionWithFileName(fileName);
}
public LiveData<List<Contribution>> fetchContributions() {
return localDataSource.getContributions();
}
public Completable save(List<Contribution> contributions) {
return localDataSource.saveContributions(contributions);
}
public void set(String key, long value) {
localDataSource.set(key,value);
}
public long getLong(String key) {
return localDataSource.getLong(key);
}
}

View file

@ -1,84 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.accounts.Account;
import android.annotation.SuppressLint;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.os.Bundle;
import android.os.RemoteException;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.mwapi.UserClient;
import timber.log.Timber;
import static fr.free.nrw.commons.contributions.Contribution.STATE_COMPLETED;
import static fr.free.nrw.commons.contributions.ContributionDao.Table.COLUMN_FILENAME;
import static fr.free.nrw.commons.contributions.ContributionsContentProvider.BASE_URI;
@SuppressWarnings("WeakerAccess")
public class ContributionsSyncAdapter extends AbstractThreadedSyncAdapter {
private static final String[] existsQuery = {COLUMN_FILENAME};
private static final String existsSelection = COLUMN_FILENAME + " = ?";
private static final ContentValues[] EMPTY = {};
@Inject
UserClient userClient;
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
public ContributionsSyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
}
private boolean fileExists(ContentProviderClient client, String filename) {
if (filename == null) {
return false;
}
try (Cursor cursor = client.query(BASE_URI,
existsQuery,
existsSelection,
new String[]{filename},
""
)) {
return cursor != null && cursor.getCount() != 0;
} catch (RemoteException e) {
throw new RuntimeException(e);
}
}
@SuppressLint("CheckResult")
@Override
public void onPerformSync(Account account, Bundle bundle, String authority,
ContentProviderClient contentProviderClient, SyncResult syncResult) {
ApplicationlessInjection
.getInstance(getContext()
.getApplicationContext())
.getCommonsApplicationComponent()
.inject(this);
// This code is(was?) fraught with possibilities of race conditions, but lalalalala I can't hear you!
String user = account.name;
ContributionDao contributionDao = new ContributionDao(() -> contentProviderClient);
userClient.logEvents(user)
.doOnNext(mwQueryLogEvent->Timber.d("Received image %s", mwQueryLogEvent.title() ))
.filter(mwQueryLogEvent -> !mwQueryLogEvent.isDeleted())
.filter(mwQueryLogEvent -> !fileExists(contentProviderClient, mwQueryLogEvent.title()))
.doOnNext(mwQueryLogEvent->Timber.d("Image %s passed filters", mwQueryLogEvent.title() ))
.map(image -> new Contribution(null, null, image.title(),
"", -1, image.date(), image.date(), user,
"", "", STATE_COMPLETED))
.map(contributionDao::toContentValues)
.buffer(10)
.subscribe(imageValues->contentProviderClient.bulkInsert(BASE_URI, imageValues.toArray(EMPTY)));
Timber.d("Oh hai, everyone! Look, a kitty!");
}
}

View file

@ -1,27 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.app.Service;
import android.content.Intent;
import android.os.IBinder;
public class ContributionsSyncService extends Service {
private static final Object sSyncAdapterLock = new Object();
private static ContributionsSyncAdapter sSyncAdapter = null;
@Override
public void onCreate() {
super.onCreate();
synchronized (sSyncAdapterLock) {
if (sSyncAdapter == null) {
sSyncAdapter = new ContributionsSyncAdapter(this, true);
}
}
}
@Override
public IBinder onBind(Intent intent) {
return sSyncAdapter.getSyncAdapterBinder();
}
}

View file

@ -113,8 +113,6 @@ public class MainActivity extends NavigationBaseActivity implements FragmentMana
private void initMain() {
//Do not remove this, this triggers the sync service
ContentResolver.setSyncAutomatically(sessionManager.getCurrentAccount(),BuildConfig.CONTRIBUTION_AUTHORITY,true);
requestSync(sessionManager.getCurrentAccount(), BuildConfig.CONTRIBUTION_AUTHORITY, new Bundle());
Intent uploadServiceIntent = new Intent(this, UploadService.class);
uploadServiceIntent.setAction(UploadService.ACTION_START_SERVICE);
startService(uploadServiceIntent);

View file

@ -22,6 +22,7 @@ public class DisplayableContribution extends Contribution {
contribution.getWidth(),
contribution.getHeight(),
contribution.getLicense());
this._id=contribution._id;
this.position = position;
}

View file

@ -2,18 +2,20 @@ package fr.free.nrw.commons.data;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
public class DBOpenHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "commons.db";
private static final int DATABASE_VERSION = 11;
private static final int DATABASE_VERSION = 12;
public static final String CONTRIBUTIONS_TABLE = "contributions";
private final String DROP_TABLE_STATEMENT="DROP TABLE IF EXISTS %s";
/**
* Do not use directly - @Inject an instance where it's needed and let
@ -25,7 +27,6 @@ public class DBOpenHelper extends SQLiteOpenHelper {
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
ContributionDao.Table.onCreate(sqLiteDatabase);
CategoryDao.Table.onCreate(sqLiteDatabase);
BookmarkPicturesDao.Table.onCreate(sqLiteDatabase);
BookmarkLocationsDao.Table.onCreate(sqLiteDatabase);
@ -34,10 +35,23 @@ public class DBOpenHelper extends SQLiteOpenHelper {
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) {
ContributionDao.Table.onUpdate(sqLiteDatabase, from, to);
CategoryDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkPicturesDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkLocationsDao.Table.onUpdate(sqLiteDatabase, from, to);
RecentSearchesDao.Table.onUpdate(sqLiteDatabase, from, to);
deleteTable(sqLiteDatabase,CONTRIBUTIONS_TABLE);
}
/**
* Delete table in the given db
* @param db
* @param tableName
*/
public void deleteTable(SQLiteDatabase db, String tableName) {
try {
db.execSQL(String.format(DROP_TABLE_STATEMENT, tableName));
} catch (SQLiteException e) {
e.printStackTrace();
}
}
}

View file

@ -0,0 +1,14 @@
package fr.free.nrw.commons.db;
import androidx.room.Database;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.contributions.ContributionDao;
@Database(entities = {Contribution.class}, version = 1, exportSchema = false)
@TypeConverters({Converters.class})
abstract public class AppDatabase extends RoomDatabase {
public abstract ContributionDao getContributionDao();
}

View file

@ -0,0 +1,76 @@
package fr.free.nrw.commons.db;
import android.net.Uri;
import androidx.room.TypeConverter;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.wikipedia.json.GsonUtil;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.location.LatLng;
public class Converters {
public static Gson getGson() {
return ApplicationlessInjection.getInstance(CommonsApplication.getInstance()).getCommonsApplicationComponent().gson();
}
@TypeConverter
public static Date fromTimestamp(Long value) {
return value == null ? null : new Date(value);
}
@TypeConverter
public static Long dateToTimestamp(Date date) {
return date == null ? null : date.getTime();
}
@TypeConverter
public static Uri fromString(String value) {
return value == null ? null : Uri.parse(value);
}
@TypeConverter
public static String uriToString(Uri uri) {
return uri == null ? null : uri.toString();
}
@TypeConverter
public static String listObjectToString(ArrayList<String> objectList) {
return objectList == null ? null : getGson().toJson(objectList);
}
@TypeConverter
public static ArrayList<String> stringToArrayListObject(String objectList) {
return objectList == null ? null : getGson().fromJson(objectList,new TypeToken<ArrayList<String>>(){}.getType());
}
@TypeConverter
public static String mapObjectToString(HashMap<String,String> objectList) {
return objectList == null ? null : getGson().toJson(objectList);
}
@TypeConverter
public static HashMap<String,String> stringToMap(String objectList) {
return objectList == null ? null : getGson().fromJson(objectList,new TypeToken<HashMap<String,String>>(){}.getType());
}
@TypeConverter
public static String latlngObjectToString(LatLng latlng) {
return latlng == null ? null : getGson().toJson(latlng);
}
@TypeConverter
public static LatLng stringToLatLng(String objectList) {
return objectList == null ? null : getGson().fromJson(objectList,LatLng.class);
}
}

View file

@ -98,7 +98,7 @@ public class DeleteHelper {
String userPageString = "\n{{subst:idw|" + media.getFilename() +
"}} ~~~~";
return pageEditClient.prependEdit(media.getFilename(), fileDeleteString + "\n", summary)
return pageEditClient.prependEdit(media.filename, fileDeleteString + "\n", summary)
.flatMap(result -> {
if (result) {
return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary);

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons.di;
import com.google.gson.Gson;
import javax.inject.Singleton;
import dagger.Component;
@ -10,7 +12,6 @@ import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.contributions.ContributionViewHolder;
import fr.free.nrw.commons.contributions.ContributionsModule;
import fr.free.nrw.commons.contributions.ContributionsSyncAdapter;
import fr.free.nrw.commons.nearby.PlaceRenderer;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.settings.SettingsFragment;
@ -37,8 +38,6 @@ import fr.free.nrw.commons.widget.PicOfDayAppWidget;
public interface CommonsApplicationComponent extends AndroidInjector<ApplicationlessInjection> {
void inject(CommonsApplication application);
void inject(ContributionsSyncAdapter syncAdapter);
void inject(LoginActivity activity);
void inject(SettingsFragment fragment);
@ -56,9 +55,12 @@ public interface CommonsApplicationComponent extends AndroidInjector<Application
void inject(ContributionViewHolder viewHolder);
Gson gson();
@Component.Builder
@SuppressWarnings({"WeakerAccess", "unused"})
interface Builder {
Builder appModule(CommonsApplicationModule applicationModule);
CommonsApplicationComponent build();

View file

@ -6,6 +6,7 @@ import android.content.Context;
import android.view.inputmethod.InputMethodManager;
import androidx.collection.LruCache;
import androidx.room.Room;
import com.github.varunpant.quadtree.QuadTree;
import com.google.gson.Gson;
@ -27,8 +28,9 @@ import fr.free.nrw.commons.BuildConfig;
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.caching.CacheController;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.settings.Prefs;
@ -53,6 +55,7 @@ public class CommonsApplicationModule {
private Context applicationContext;
public static final String IO_THREAD="io_thread";
public static final String MAIN_THREAD="main_thread";
private AppDatabase appDatabase;
public CommonsApplicationModule(Context applicationContext) {
this.applicationContext = applicationContext;
@ -229,4 +232,16 @@ public class CommonsApplicationModule {
public QuadTree providesQuadTres() {
return new QuadTree<>(-180, -90, +180, +90);
}
@Provides
@Singleton
public AppDatabase provideAppDataBase() {
appDatabase=Room.databaseBuilder(applicationContext, AppDatabase.class, "commons_room.db").build();
return appDatabase;
}
@Provides
public ContributionDao providesContributionsDao() {
return appDatabase.getContributionDao();
}
}

View file

@ -5,7 +5,6 @@ import dagger.android.ContributesAndroidInjector;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider;
import fr.free.nrw.commons.category.CategoryContentProvider;
import fr.free.nrw.commons.contributions.ContributionsContentProvider;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider;
/**
@ -17,9 +16,6 @@ import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider;
@SuppressWarnings({"WeakerAccess", "unused"})
public abstract class ContentProviderBuilderModule {
@ContributesAndroidInjector
abstract ContributionsContentProvider bindContributionsContentProvider();
@ContributesAndroidInjector
abstract CategoryContentProvider bindCategoryContentProvider();

View file

@ -205,6 +205,7 @@ public class UploadModel {
contribution.setTag("mimeType", item.mimeType);
contribution.setSource(item.source);
contribution.setContentProviderUri(item.mediaUri);
contribution.setDateUploaded(new Date());
Timber.d("Created timestamp while building contribution is %s, %s",
item.getCreatedTimestamp(),

View file

@ -3,7 +3,6 @@ package fr.free.nrw.commons.upload;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.net.Uri;
@ -20,6 +19,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.CommonsApplication;
@ -28,12 +28,16 @@ import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.contributions.ContributionsContentProvider;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import fr.free.nrw.commons.media.MediaClient;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.wikidata.WikidataEditService;
import io.reactivex.Observable;
import io.reactivex.Scheduler;
import io.reactivex.SingleObserver;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
@ -46,16 +50,22 @@ public class UploadService extends HandlerService<Contribution> {
public static final String ACTION_START_SERVICE = EXTRA_PREFIX + ".upload";
public static final String EXTRA_SOURCE = EXTRA_PREFIX + ".source";
public static final String EXTRA_FILES = EXTRA_PREFIX + ".files";
@Inject WikidataEditService wikidataEditService;
@Inject SessionManager sessionManager;
@Inject ContributionDao contributionDao;
@Inject
ContributionDao contributionDao;
@Inject UploadClient uploadClient;
@Inject MediaClient mediaClient;
@Inject
@Named(CommonsApplicationModule.MAIN_THREAD)
Scheduler mainThreadScheduler;
@Inject
@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler;
private NotificationManagerCompat notificationManager;
private NotificationCompat.Builder curNotification;
private int toUpload;
private CompositeDisposable compositeDisposable;
/**
* The filePath names of unfinished uploads, used to prevent overwriting
@ -105,7 +115,10 @@ public class UploadService extends HandlerService<Contribution> {
notificationManager.notify(notificationTag, NOTIFICATION_UPLOAD_IN_PROGRESS, curNotification.build());
contribution.setTransferred(transferred);
contributionDao.save(contribution);
compositeDisposable.add(contributionDao.
save(contribution).subscribeOn(ioThreadScheduler)
.observeOn(mainThreadScheduler)
.subscribe());
}
}
@ -113,6 +126,7 @@ public class UploadService extends HandlerService<Contribution> {
@Override
public void onDestroy() {
super.onDestroy();
compositeDisposable.dispose();
Timber.d("UploadService.onDestroy; %s are yet to be uploaded", unfinishedUploads);
}
@ -120,6 +134,7 @@ public class UploadService extends HandlerService<Contribution> {
public void onCreate() {
super.onCreate();
CommonsApplication.createNotificationChannel(getApplicationContext());
compositeDisposable = new CompositeDisposable();
notificationManager = NotificationManagerCompat.from(this);
curNotification = getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL);
}
@ -143,15 +158,20 @@ public class UploadService extends HandlerService<Contribution> {
contribution.setState(Contribution.STATE_QUEUED);
contribution.setTransferred(0);
contributionDao.save(contribution);
toUpload++;
if (curNotification != null && toUpload != 1) {
curNotification.setContentText(getResources().getQuantityString(R.plurals.uploads_pending_notification_indicator, toUpload, toUpload));
Timber.d("%d uploads left", toUpload);
notificationManager.notify(contribution.getLocalUri().toString(), NOTIFICATION_UPLOAD_IN_PROGRESS, curNotification.build());
}
super.queue(what, contribution);
compositeDisposable.add(contributionDao
.save(contribution)
.subscribeOn(ioThreadScheduler)
.observeOn(mainThreadScheduler)
.subscribe(aLong->{
contribution._id = aLong;
UploadService.super.queue(what, contribution);
}, Throwable::printStackTrace));
break;
default:
throw new IllegalArgumentException("Unknown value for what");
@ -163,16 +183,10 @@ public class UploadService extends HandlerService<Contribution> {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (ACTION_START_SERVICE.equals(intent.getAction()) && freshStart) {
ContentValues failedValues = new ContentValues();
failedValues.put(ContributionDao.Table.COLUMN_STATE, Contribution.STATE_FAILED);
int updated = getContentResolver().update(ContributionsContentProvider.BASE_URI,
failedValues,
ContributionDao.Table.COLUMN_STATE + " = ? OR " + ContributionDao.Table.COLUMN_STATE + " = ?",
new String[]{ String.valueOf(Contribution.STATE_QUEUED), String.valueOf(Contribution.STATE_IN_PROGRESS) }
);
Timber.d("Set %d uploads to failed", updated);
Timber.d("Flags is %d id is %d", flags, startId);
compositeDisposable.add(contributionDao.updateStates(Contribution.STATE_FAILED, new int[]{Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS})
.observeOn(mainThreadScheduler)
.subscribeOn(ioThreadScheduler)
.subscribe());
freshStart = false;
}
return START_REDELIVER_INTENT;
@ -272,7 +286,11 @@ public class UploadService extends HandlerService<Contribution> {
contribution.setState(Contribution.STATE_COMPLETED);
contribution.setDateUploaded(CommonsDateUtil.getIso8601DateFormatShort()
.parse(uploadResult.getImageinfo().getTimestamp()));
contributionDao.save(contribution);
compositeDisposable.add(contributionDao
.save(contribution)
.subscribeOn(ioThreadScheduler)
.observeOn(mainThreadScheduler)
.subscribe());
}
}, throwable -> {
Timber.w(throwable, "Exception during upload");
@ -291,7 +309,10 @@ public class UploadService extends HandlerService<Contribution> {
notificationManager.notify(contribution.getLocalUri().toString(), NOTIFICATION_UPLOAD_FAILED, curNotification.build());
contribution.setState(Contribution.STATE_FAILED);
contributionDao.save(contribution);
compositeDisposable.add(contributionDao.save(contribution)
.subscribeOn(ioThreadScheduler)
.observeOn(mainThreadScheduler)
.subscribe());
}
private String findUniqueFilename(String fileName) throws IOException {

View file

@ -25,6 +25,14 @@ class TestCommonsApplication : Application() {
.build()
}
super.onCreate()
context=applicationContext
}
companion object{
private var context: Context?=null
fun getContext(): Context? {
return context
}
}
}

View file

@ -1,348 +0,0 @@
package fr.free.nrw.commons.contributions
import android.content.ContentProviderClient
import android.content.ContentValues
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.RemoteException
import com.nhaarman.mockitokotlin2.*
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.contributions.Contribution.*
import fr.free.nrw.commons.contributions.ContributionDao.Table
import fr.free.nrw.commons.contributions.ContributionsContentProvider.BASE_URI
import fr.free.nrw.commons.contributions.ContributionsContentProvider.uriForId
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.*
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [21], application = TestCommonsApplication::class)
class ContributionDaoTest {
private val localUri = "http://example.com/"
private val client: ContentProviderClient = mock()
private val database: SQLiteDatabase = mock()
private val captor = argumentCaptor<ContentValues>()
private lateinit var contentUri: Uri
private lateinit var testObject: ContributionDao
@Before
fun setUp() {
contentUri = uriForId(111)
testObject = ContributionDao { client }
}
@Test
fun createTable() {
Table.onCreate(database)
verify(database).execSQL(Table.CREATE_TABLE_STATEMENT)
}
@Test
fun deleteTable() {
Table.onDelete(database)
inOrder(database) {
verify(database).execSQL(Table.DROP_TABLE_STATEMENT)
verify(database).execSQL(Table.CREATE_TABLE_STATEMENT)
}
}
@Test
fun upgradeDatabase_v1_to_v2() {
Table.onUpdate(database, 1, 2)
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_DESCRIPTION_FIELD)
verify<SQLiteDatabase>(database).execSQL(Table.ADD_CREATOR_FIELD)
}
}
@Test
fun upgradeDatabase_v2_to_v3() {
Table.onUpdate(database, 2, 3)
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_MULTIPLE_FIELD)
verify<SQLiteDatabase>(database).execSQL(Table.SET_DEFAULT_MULTIPLE)
}
}
@Test
fun upgradeDatabase_v3_to_v4() {
Table.onUpdate(database, 3, 4)
}
@Test
fun upgradeDatabase_v4_to_v5() {
Table.onUpdate(database, 4, 5)
}
@Test
fun upgradeDatabase_v5_to_v6() {
Table.onUpdate(database, 5, 6)
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_WIDTH_FIELD)
verify<SQLiteDatabase>(database).execSQL(Table.SET_DEFAULT_WIDTH)
verify<SQLiteDatabase>(database).execSQL(Table.ADD_HEIGHT_FIELD)
verify<SQLiteDatabase>(database).execSQL(Table.SET_DEFAULT_HEIGHT)
verify<SQLiteDatabase>(database).execSQL(Table.ADD_LICENSE_FIELD)
verify<SQLiteDatabase>(database).execSQL(Table.SET_DEFAULT_LICENSE)
}
}
@Test
fun migrateTableVersionFrom_v6_to_v7() {
Table.onUpdate(database, 6, 7)
// Table has changed in version 7
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_WIKI_DATA_ENTITY_ID_FIELD)
}
}
@Test
fun migrateTableVersionFrom_v7_to_v8() {
Table.onUpdate(database, 7, 8)
// Table has changed in version 8
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_WIKI_DATA_ENTITY_ID_FIELD)
}
}
@Test
fun migrateTableVersionFrom_v8_to_v9() {
Table.onUpdate(database, 8, 9)
// Table changed in version 9
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_WIKI_DATA_ENTITY_ID_FIELD)
}
}
@Test
fun migrateTableVersionFrom_v9_to_v10() {
Table.onUpdate(database, 8, 9)
// Table changed in version 9
inOrder(database) {
verify<SQLiteDatabase>(database).execSQL(Table.ADD_WIKI_DATA_ENTITY_ID_FIELD)
}
}
@Test
fun saveNewContribution_nonNullFields() {
whenever(client.insert(isA(), isA())).thenReturn(contentUri)
val contribution = createContribution(true, null, null, null, null)
testObject.save(contribution)
assertEquals(contentUri, contribution.contentUri)
verify(client).insert(eq(BASE_URI), captor.capture())
captor.firstValue.let {
// Long fields
assertEquals(222L, it.getAsLong(Table.COLUMN_LENGTH))
assertEquals(321L, it.getAsLong(Table.COLUMN_TIMESTAMP))
assertEquals(333L, it.getAsLong(Table.COLUMN_TRANSFERRED))
// Integer fields
assertEquals(STATE_COMPLETED, it.getAsInteger(Table.COLUMN_STATE))
assertEquals(640, it.getAsInteger(Table.COLUMN_WIDTH))
assertEquals(480, it.getAsInteger(Table.COLUMN_HEIGHT))
// String fields
assertEquals(SOURCE_CAMERA, it.getAsString(Table.COLUMN_SOURCE))
assertEquals("desc", it.getAsString(Table.COLUMN_DESCRIPTION))
assertEquals("create", it.getAsString(Table.COLUMN_CREATOR))
assertEquals("007", it.getAsString(Table.COLUMN_LICENSE))
}
}
@Test
fun saveNewContribution_nullableFieldsAreNull() {
whenever(client.insert(isA(), isA())).thenReturn(contentUri)
val contribution = createContribution(true, null, null, null, null)
testObject.save(contribution)
assertEquals(contentUri, contribution.contentUri)
verify(client).insert(eq(BASE_URI), captor.capture())
captor.firstValue.let {
// Nullable fields are absent if null
assertFalse(it.containsKey(Table.COLUMN_LOCAL_URI))
assertFalse(it.containsKey(Table.COLUMN_IMAGE_URL))
assertFalse(it.containsKey(Table.COLUMN_UPLOADED))
}
}
@Test
fun saveNewContribution_nullableFieldsAreNonNull() {
whenever(client.insert(isA(), isA())).thenReturn(contentUri)
val contribution = createContribution(true, Uri.parse(localUri),
"image", Date(456L), null)
testObject.save(contribution)
assertEquals(contentUri, contribution.contentUri)
verify(client).insert(eq(BASE_URI), captor.capture())
captor.firstValue.let {
assertEquals(localUri, it.getAsString(Table.COLUMN_LOCAL_URI))
assertEquals("image", it.getAsString(Table.COLUMN_IMAGE_URL))
assertEquals(456L, it.getAsLong(Table.COLUMN_UPLOADED))
}
}
@Test
fun saveNewContribution_booleanEncodesTrue() {
whenever(client.insert(isA(), isA())).thenReturn(contentUri)
val contribution = createContribution(true, null, null, null, null)
testObject.save(contribution)
assertEquals(contentUri, contribution.contentUri)
verify(client).insert(eq(BASE_URI), captor.capture())
// Boolean true --> 1 for ths encoding scheme
assertEquals("Boolean true should be encoded as 1", 1,
captor.firstValue.getAsInteger(Table.COLUMN_MULTIPLE))
}
@Test
fun saveNewContribution_booleanEncodesFalse() {
whenever(client.insert(isA(), isA())).thenReturn(contentUri)
val contribution = createContribution(false, null, null, null, null)
testObject.save(contribution)
assertEquals(contentUri, contribution.contentUri)
verify(client).insert(eq(BASE_URI), captor.capture())
// Boolean true --> 1 for ths encoding scheme
assertEquals("Boolean false should be encoded as 0", 0,
captor.firstValue.getAsInteger(Table.COLUMN_MULTIPLE))
}
@Test
fun saveExistingContribution() {
val contribution = createContribution(false, null, null, null, null)
contribution.contentUri = contentUri
testObject.save(contribution)
verify(client).update(eq(contentUri), isA(), isNull(), isNull())
}
@Test(expected = RuntimeException::class)
fun saveTranslatesExceptions() {
whenever(client.insert(isA(), isA())).thenThrow(RemoteException(""))
testObject.save(createContribution(false, null, null, null, null))
}
@Test(expected = RuntimeException::class)
fun deleteTranslatesExceptions() {
whenever(client.delete(anyOrNull(), anyOrNull(), anyOrNull())).thenThrow(RemoteException(""))
val contribution = createContribution(false, null, null, null, null)
contribution.contentUri = contentUri
testObject.delete(contribution)
}
@Test(expected = RuntimeException::class)
fun exceptionThrownWhenAttemptingToDeleteUnsavedContribution() {
testObject.delete(createContribution(false, null, null, null, null))
}
@Test
fun deleteExistingContribution() {
val contribution = createContribution(false, null, null, null, null)
contribution.contentUri = contentUri
testObject.delete(contribution)
verify(client).delete(eq(contentUri), isNull(), isNull())
}
@Test
fun createFromCursor() {
val created = 321L
val uploaded = 456L
createCursor(created, uploaded, false, localUri).let { mc ->
testObject.fromCursor(mc).let {
assertEquals(uriForId(111), it.contentUri)
assertEquals("filePath", it.filename)
assertEquals(localUri, it.localUri.toString())
assertEquals("image", it.imageUrl)
assertEquals(created, it.dateCreated.time)
assertEquals(STATE_QUEUED, it.state)
assertEquals(222L, it.dataLength)
assertEquals(uploaded, it.dateUploaded?.time)
assertEquals(88L, it.transferred)
assertEquals(SOURCE_GALLERY, it.source)
assertEquals("desc", it.description)
assertEquals("create", it.creator)
assertEquals(640, it.width)
assertEquals(480, it.height)
assertEquals("007", it.license)
}
}
}
@Test
fun createFromCursor_nullableTimestamps() {
createCursor(0L, 0L, false, localUri).let { mc ->
testObject.fromCursor(mc).let {
assertNull(it.dateCreated)
assertNull(it.dateUploaded)
}
}
}
@Test
fun createFromCursor_nullableLocalUri() {
createCursor(0L, 0L, false, "").let { mc ->
testObject.fromCursor(mc).let {
assertNull(it.localUri)
assertNull(it.dateCreated)
assertNull(it.dateUploaded)
}
}
}
@Test
fun createFromCursor_booleanEncoding() {
val mcFalse = createCursor(0L, 0L, false, localUri)
assertFalse(testObject.fromCursor(mcFalse).multiple)
val mcHammer = createCursor(0L, 0L, true, localUri)
assertTrue(testObject.fromCursor(mcHammer).multiple)
}
private fun createCursor(created: Long, uploaded: Long, multiple: Boolean, localUri: String) =
MatrixCursor(Table.ALL_FIELDS, 1).apply {
addRow(listOf("111", "filePath", localUri, "image",
created, STATE_QUEUED, 222L, uploaded, 88L, SOURCE_GALLERY, "desc",
"create", if (multiple) 1 else 0, 640, 480, "007", "Q1"))
moveToFirst()
}
private fun createContribution(isMultiple: Boolean, localUri: Uri?, imageUrl: String?, dateUploaded: Date?, filename: String?): Contribution {
val contribution = Contribution(localUri, imageUrl, filename, "desc", 222L, Date(321L), dateUploaded,
"create", "edit", "coords").apply {
state = STATE_COMPLETED
transferred = 333L
source = SOURCE_CAMERA
license = "007"
multiple = isMultiple
width = 640
height = 480 // VGA should be enough for anyone, right?
}
contribution.wikiDataEntityId = "Q1"
return contribution
}
}

View file

@ -1,11 +1,22 @@
package fr.free.nrw.commons.contributions
import android.database.Cursor
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.loader.content.CursorLoader
import androidx.loader.content.Loader
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import io.reactivex.Scheduler
import io.reactivex.Single
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
@ -15,11 +26,11 @@ import org.mockito.MockitoAnnotations
*/
class ContributionsPresenterTest {
@Mock
internal var repository: ContributionsRepository? = null
internal lateinit var repository: ContributionsRepository
@Mock
internal var view: ContributionsContract.View? = null
internal lateinit var view: ContributionsContract.View
private var contributionsPresenter: ContributionsPresenter? = null
private lateinit var contributionsPresenter: ContributionsPresenter
private lateinit var cursor: Cursor
@ -27,6 +38,12 @@ class ContributionsPresenterTest {
lateinit var loader: Loader<Cursor>
lateinit var liveData: LiveData<List<Contribution>>
@Rule @JvmField var instantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var scheduler : Scheduler
/**
* initial setup
*/
@ -34,21 +51,24 @@ class ContributionsPresenterTest {
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
scheduler=TestScheduler()
cursor = Mockito.mock(Cursor::class.java)
contribution = Mockito.mock(Contribution::class.java)
contributionsPresenter = ContributionsPresenter(repository)
contributionsPresenter = ContributionsPresenter(repository,scheduler,scheduler)
loader = Mockito.mock(CursorLoader::class.java)
contributionsPresenter?.onAttachView(view)
contributionsPresenter.onAttachView(view)
liveData=MutableLiveData()
}
/**
* Test presenter actions onGetContributionFromCursor
* Test fetch contributions
*/
@Test
fun testGetContributionFromCursor() {
contributionsPresenter?.getContributionsFromCursor(cursor)
verify(repository)?.getContributionFromCursor(cursor)
fun testFetchContributions(){
whenever(repository.getString(ArgumentMatchers.anyString())).thenReturn("10")
whenever(repository.fetchContributions()).thenReturn(liveData)
contributionsPresenter.fetchContributions()
verify(repository).fetchContributions()
}
/**
@ -56,55 +76,20 @@ class ContributionsPresenterTest {
*/
@Test
fun testDeleteContribution() {
contributionsPresenter?.deleteUpload(contribution)
verify(repository)?.deleteContributionFromDB(contribution)
whenever(repository.deleteContributionFromDB(ArgumentMatchers.any(Contribution::class.java))).thenReturn(Single.just(1))
contributionsPresenter.deleteUpload(contribution)
verify(repository).deleteContributionFromDB(contribution)
}
/**
* Test presenter actions on loaderFinished and has non zero media objects
* Test fetch contribution with filename
*/
@Test
fun testOnLoaderFinishedNonZeroContributions() {
Mockito.`when`(cursor.count).thenReturn(1)
contributionsPresenter?.onLoadFinished(loader, cursor)
verify(view)?.showProgress(false)
verify(view)?.showWelcomeTip(false)
verify(view)?.showNoContributionsUI(false)
verify(view)?.setUploadCount(cursor.count)
}
/**
* Test presenter actions on loaderFinished and has Zero media objects
*/
@Test
fun testOnLoaderFinishedZeroContributions() {
Mockito.`when`(cursor.count).thenReturn(0)
contributionsPresenter?.onLoadFinished(loader, cursor)
verify(view)?.showProgress(false)
verify(view)?.showWelcomeTip(true)
verify(view)?.showNoContributionsUI(true)
fun testGetContributionWithFileName(){
contributionsPresenter.getContributionsWithTitle("ashish")
verify(repository).getContributionWithFileName("ashish")
}
/**
* Test presenter actions on loader reset
*/
@Test
fun testOnLoaderReset() {
contributionsPresenter?.onLoaderReset(loader)
verify(view)?.showProgress(false)
verify(view)?.showWelcomeTip(true)
verify(view)?.showNoContributionsUI(true)
}
/**
* Test presenter actions on loader change
*/
@Test
fun testOnChanged() {
contributionsPresenter?.onChanged()
verify(view)?.onDataSetChanged()
}
}

View file

@ -63,7 +63,7 @@ class DeleteHelperTest {
.thenReturn(Observable.just(true))
`when`(media?.displayTitle).thenReturn("Test file")
`when`(media?.filename).thenReturn("Test file.jpg")
media?.filename="Test file.jpg"
val makeDeletion = deleteHelper?.makeDeletion(context, media, "Test reason")?.blockingGet()
assertNotNull(makeDeletion)

View file

@ -55,8 +55,8 @@ class ReasonBuilderTest {
`when`(okHttpJsonApiClient!!.getAchievements(anyString()))
.thenReturn(Single.just(mock(FeedbackResponse::class.java)))
val media = mock(Media::class.java)
`when`(media!!.dateUploaded).thenReturn(Date())
val media = Media("test_file")
media.dateUploaded=Date()
reasonBuilder!!.getReason(media, "test")
verify(sessionManager, times(0))!!.forceLogin(any(Context::class.java))

View file

@ -61,7 +61,7 @@ class ReviewHelperTest {
.thenReturn(Observable.just(mockResponse))
val media = mock(Media::class.java)
`when`(media.filename).thenReturn("File:Test.jpg")
media.filename="File:Test.jpg"
`when`(mediaClient?.getMedia(ArgumentMatchers.anyString()))
.thenReturn(Single.just(media))
}
@ -74,10 +74,10 @@ class ReviewHelperTest {
`when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString()))
.thenReturn(Single.just(false))
val randomMedia = reviewHelper?.randomMedia?.blockingGet()
`when`(mediaClient?.checkPageExistsUsingTitle(ArgumentMatchers.anyString()))
.thenReturn(Single.just(false))
assertNotNull(randomMedia)
assertTrue(randomMedia is Media)
reviewHelper?.randomMedia
verify(reviewInterface, times(1))!!.getRecentChanges(ArgumentMatchers.anyString())
}
@ -105,10 +105,7 @@ class ReviewHelperTest {
`when`(mediaClient?.checkPageExistsUsingTitle("Commons:Deletion_requests/File:Test3.jpg"))
.thenReturn(Single.just(true))
val media = reviewHelper?.randomMedia?.blockingGet()
assertNotNull(media)
assertTrue(media is Media)
reviewHelper?.randomMedia
verify(reviewInterface, times(1))!!.getRecentChanges(ArgumentMatchers.anyString())
}