diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt index 1cb50b8f3b..7e6fee2fca 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -78,7 +78,13 @@ class CategoriesModel // Newly used category... if (category == null) { - category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) + category = Category( + null, item.name, + item.description, + item.thumbnail, + Date(), + 0 + ) } category.incTimesUsed() categoryDao.save(category) diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.java b/app/src/main/java/fr/free/nrw/commons/category/Category.java deleted file mode 100644 index 32bba67baf..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/Category.java +++ /dev/null @@ -1,115 +0,0 @@ -package fr.free.nrw.commons.category; - -import android.net.Uri; - -import java.util.Date; - -/** - * Represents a category - */ -public class Category { - private Uri contentUri; - private String name; - private String description; - private String thumbnail; - private Date lastUsed; - private int timesUsed; - - public Category() { - } - - public Category(Uri contentUri, String name, String description, String thumbnail, Date lastUsed, int timesUsed) { - this.contentUri = contentUri; - this.name = name; - this.description = description; - this.thumbnail = thumbnail; - this.lastUsed = lastUsed; - this.timesUsed = timesUsed; - } - - /** - * Gets name - * - * @return name - */ - public String getName() { - return name; - } - - /** - * Modifies name - * - * @param name Category name - */ - public void setName(String name) { - this.name = name; - } - - /** - * Gets last used date - * - * @return Last used date - */ - public Date getLastUsed() { - // warning: Date objects are mutable. - return (Date)lastUsed.clone(); - } - - /** - * Generates new last used date - */ - private void touch() { - lastUsed = new Date(); - } - - /** - * Gets no. of times the category is used - * - * @return no. of times used - */ - public int getTimesUsed() { - return timesUsed; - } - - /** - * Increments timesUsed by 1 and sets last used date as now. - */ - public void incTimesUsed() { - timesUsed++; - touch(); - } - - /** - * Gets the content URI for this category - * - * @return content URI - */ - public Uri getContentUri() { - return contentUri; - } - - /** - * Modifies the content URI - marking this category as already saved in the database - * - * @param contentUri the content URI - */ - public void setContentUri(Uri contentUri) { - this.contentUri = contentUri; - } - - public String getDescription() { - return description; - } - - public String getThumbnail() { - return thumbnail; - } - - public void setDescription(final String description) { - this.description = description; - } - - public void setThumbnail(final String thumbnail) { - this.thumbnail = thumbnail; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/Category.kt b/app/src/main/java/fr/free/nrw/commons/category/Category.kt new file mode 100644 index 0000000000..e4bfb957a2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/Category.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.category + +import android.net.Uri +import java.util.Date + +data class Category( + var contentUri: Uri? = null, + val name: String? = null, + val description: String? = null, + val thumbnail: String? = null, + val lastUsed: Date? = null, + var timesUsed: Int = 0 +) { + fun incTimesUsed() { + timesUsed++ + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java deleted file mode 100644 index df99b40603..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package fr.free.nrw.commons.category; - -public interface CategoryClickedListener { - void categoryClicked(CategoryItem item); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt new file mode 100644 index 0000000000..ef4ec3d39b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryClickedListener.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface CategoryClickedListener { + fun categoryClicked(item: CategoryItem) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java deleted file mode 100644 index 01793ca954..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.java +++ /dev/null @@ -1,169 +0,0 @@ -package fr.free.nrw.commons.category; - -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.category.CategoryDao.Table.ALL_FIELDS; -import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID; -import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME; - -public class CategoryContentProvider extends CommonsDaggerContentProvider { - - // For URI matcher - private static final int CATEGORIES = 1; - private static final int CATEGORIES_ID = 2; - private static final String BASE_PATH = "categories"; - - public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH); - - private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH); - - static { - uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES); - uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_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 CATEGORIES: - cursor = queryBuilder.query(db, projection, selection, selectionArgs, - null, null, sortOrder); - break; - case CATEGORIES_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 CATEGORIES: - 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); - } - - @Override - public int delete(@NonNull Uri uri, String s, String[] strings) { - return 0; - } - - @SuppressWarnings("ConstantConditions") - @Override - public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) { - Timber.d("Hello, bulk insert! (CategoryContentProvider)"); - int uriType = uriMatcher.match(uri); - SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); - sqlDB.beginTransaction(); - switch (uriType) { - case CATEGORIES: - 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 concating. - - 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 CATEGORIES_ID: - if (TextUtils.isEmpty(selection)) { - int id = Integer.valueOf(uri.getLastPathSegment()); - rowsUpdated = sqlDB.update(TABLE_NAME, - contentValues, - 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; - } -} - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt new file mode 100644 index 0000000000..ddd7f5ae41 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryContentProvider.kt @@ -0,0 +1,205 @@ +package fr.free.nrw.commons.category + + +import android.content.ContentValues +import android.content.UriMatcher +import android.content.UriMatcher.NO_MATCH +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 fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.data.DBOpenHelper +import fr.free.nrw.commons.di.CommonsDaggerContentProvider +import timber.log.Timber +import javax.inject.Inject + +class CategoryContentProvider : CommonsDaggerContentProvider() { + + private val uriMatcher = UriMatcher(NO_MATCH).apply { + addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES) + addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID) + } + + @Inject + lateinit var dbOpenHelper: DBOpenHelper + + @SuppressWarnings("ConstantConditions") + override fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? { + val queryBuilder = SQLiteQueryBuilder().apply { + tables = TABLE_NAME + } + + val uriType = uriMatcher.match(uri) + val db = dbOpenHelper.readableDatabase + + val cursor: Cursor? = when (uriType) { + CATEGORIES -> queryBuilder.query( + db, + projection, + selection, + selectionArgs, + null, + null, + sortOrder + ) + CATEGORIES_ID -> queryBuilder.query( + db, + ALL_FIELDS, + "_id = ?", + arrayOf(uri.lastPathSegment), + null, + null, + sortOrder + ) + else -> throw IllegalArgumentException("Unknown URI $uri") + } + + cursor?.setNotificationUri(context?.contentResolver, uri) + return cursor + } + + override fun getType(uri: Uri): String? { + return null + } + + @SuppressWarnings("ConstantConditions") + override fun insert(uri: Uri, contentValues: ContentValues?): Uri? { + val uriType = uriMatcher.match(uri) + val sqlDB = dbOpenHelper.writableDatabase + val id: Long + when (uriType) { + CATEGORIES -> { + id = sqlDB.insert(TABLE_NAME, null, contentValues) + } + else -> throw IllegalArgumentException("Unknown URI: $uri") + } + context?.contentResolver?.notifyChange(uri, null) + return Uri.parse("${Companion.BASE_URI}/$id") + } + + @SuppressWarnings("ConstantConditions") + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + // Not implemented + return 0 + } + + @SuppressWarnings("ConstantConditions") + override fun bulkInsert(uri: Uri, values: Array): Int { + Timber.d("Hello, bulk insert! (CategoryContentProvider)") + val uriType = uriMatcher.match(uri) + val sqlDB = dbOpenHelper.writableDatabase + sqlDB.beginTransaction() + when (uriType) { + CATEGORIES -> { + for (value in values) { + Timber.d("Inserting! %s", value) + sqlDB.insert(TABLE_NAME, null, value) + } + sqlDB.setTransactionSuccessful() + } + else -> throw IllegalArgumentException("Unknown URI: $uri") + } + sqlDB.endTransaction() + context?.contentResolver?.notifyChange(uri, null) + return values.size + } + + @SuppressWarnings("ConstantConditions") + override fun update(uri: Uri, contentValues: ContentValues?, selection: String?, + selectionArgs: Array?): Int { + val uriType = uriMatcher.match(uri) + val sqlDB = dbOpenHelper.writableDatabase + val rowsUpdated: Int + when (uriType) { + CATEGORIES_ID -> { + if (TextUtils.isEmpty(selection)) { + val id = uri.lastPathSegment?.toInt() + ?: throw IllegalArgumentException("Invalid ID") + rowsUpdated = sqlDB.update(TABLE_NAME, + contentValues, + "$COLUMN_ID = ?", + arrayOf(id.toString())) + } else { + throw IllegalArgumentException( + "Parameter `selection` should be empty when updating an ID") + } + } + else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType") + } + context?.contentResolver?.notifyChange(uri, null) + return rowsUpdated + } + + companion object { + const val TABLE_NAME = "categories" + + const val COLUMN_ID = "_id" + const val COLUMN_NAME = "name" + const val COLUMN_DESCRIPTION = "description" + const val COLUMN_THUMBNAIL = "thumbnail" + const val COLUMN_LAST_USED = "last_used" + const val COLUMN_TIMES_USED = "times_used" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + val ALL_FIELDS = arrayOf( + COLUMN_ID, + COLUMN_NAME, + COLUMN_DESCRIPTION, + COLUMN_THUMBNAIL, + COLUMN_LAST_USED, + COLUMN_TIMES_USED + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + + "$COLUMN_ID INTEGER PRIMARY KEY," + + "$COLUMN_NAME TEXT," + + "$COLUMN_DESCRIPTION TEXT," + + "$COLUMN_THUMBNAIL TEXT," + + "$COLUMN_LAST_USED INTEGER," + + "$COLUMN_TIMES_USED INTEGER" + + ");" + + fun uriForId(id: Int): Uri { + return Uri.parse("${BASE_URI}/$id") + } + + fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_TABLE_STATEMENT) + } + + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) return + if (from < 4) { + // doesn't exist yet + onUpdate(db, from + 1, to) + } else if (from == 4) { + // table added in version 5 + onCreate(db) + onUpdate(db, from + 1, to) + } else if (from == 5) { + onUpdate(db, from + 1, to) + } else if (from == 17) { + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;") + onUpdate(db, from + 1, to) + } + } + + // For URI matcher + private const val CATEGORIES = 1 + private const val CATEGORIES_ID = 2 + private const val BASE_PATH = "categories" + val BASE_URI: Uri = Uri.parse("content://${BuildConfig.CATEGORY_AUTHORITY}/${Companion.BASE_PATH}") + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java deleted file mode 100644 index 3cd60ac81a..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.java +++ /dev/null @@ -1,209 +0,0 @@ -package fr.free.nrw.commons.category; - -import android.annotation.SuppressLint; -import android.content.ContentProviderClient; -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.os.RemoteException; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Provider; - -public class CategoryDao { - - private final Provider clientProvider; - - @Inject - public CategoryDao(@Named("category") Provider clientProvider) { - this.clientProvider = clientProvider; - } - - public void save(Category category) { - ContentProviderClient db = clientProvider.get(); - try { - if (category.getContentUri() == null) { - category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category))); - } else { - db.update(category.getContentUri(), toContentValues(category), null, null); - } - } catch (RemoteException e) { - throw new RuntimeException(e); - } finally { - db.release(); - } - } - - /** - * Find persisted category in database, based on its name. - * - * @param name Category's name - * @return category from database, or null if not found - */ - @Nullable - Category find(String name) { - Cursor cursor = null; - ContentProviderClient db = clientProvider.get(); - try { - cursor = db.query( - CategoryContentProvider.BASE_URI, - Table.ALL_FIELDS, - Table.COLUMN_NAME + "=?", - new String[]{name}, - null); - if (cursor != null && cursor.moveToFirst()) { - return fromCursor(cursor); - } - } catch (RemoteException e) { - // This feels lazy, but to hell with checked exceptions. :) - throw new RuntimeException(e); - } finally { - if (cursor != null) { - cursor.close(); - } - db.release(); - } - return null; - } - - /** - * Retrieve recently-used categories, ordered by descending date. - * - * @return a list containing recent categories - */ - @NonNull - List recentCategories(int limit) { - List items = new ArrayList<>(); - Cursor cursor = null; - ContentProviderClient db = clientProvider.get(); - try { - cursor = db.query( - CategoryContentProvider.BASE_URI, - Table.ALL_FIELDS, - null, - new String[]{}, - Table.COLUMN_LAST_USED + " DESC"); - // fixme add a limit on the original query instead of falling out of the loop? - while (cursor != null && cursor.moveToNext() - && cursor.getPosition() < limit) { - if (fromCursor(cursor).getName() != null ) { - items.add(new CategoryItem(fromCursor(cursor).getName(), - fromCursor(cursor).getDescription(), fromCursor(cursor).getThumbnail(), - false)); - } - } - } catch (RemoteException e) { - throw new RuntimeException(e); - } finally { - if (cursor != null) { - cursor.close(); - } - db.release(); - } - return items; - } - - @NonNull - @SuppressLint("Range") - Category fromCursor(Cursor cursor) { - // Hardcoding column positions! - return new Category( - CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))), - cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)), - cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)), - cursor.getString(cursor.getColumnIndex(Table.COLUMN_THUMBNAIL)), - new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))), - cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED)) - ); - } - - private ContentValues toContentValues(Category category) { - ContentValues cv = new ContentValues(); - cv.put(CategoryDao.Table.COLUMN_NAME, category.getName()); - cv.put(Table.COLUMN_DESCRIPTION, category.getDescription()); - cv.put(Table.COLUMN_THUMBNAIL, category.getThumbnail()); - cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime()); - cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed()); - return cv; - } - - public static class Table { - public static final String TABLE_NAME = "categories"; - - public static final String COLUMN_ID = "_id"; - static final String COLUMN_NAME = "name"; - static final String COLUMN_DESCRIPTION = "description"; - static final String COLUMN_THUMBNAIL = "thumbnail"; - static final String COLUMN_LAST_USED = "last_used"; - static final String COLUMN_TIMES_USED = "times_used"; - - // 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_NAME, - COLUMN_DESCRIPTION, - COLUMN_THUMBNAIL, - COLUMN_LAST_USED, - COLUMN_TIMES_USED - }; - - static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME; - - static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" - + COLUMN_ID + " INTEGER PRIMARY KEY," - + COLUMN_NAME + " STRING," - + COLUMN_DESCRIPTION + " STRING," - + COLUMN_THUMBNAIL + " STRING," - + COLUMN_LAST_USED + " INTEGER," - + COLUMN_TIMES_USED + " INTEGER" - + ");"; - - 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; - } - if (from < 4) { - // doesn't exist yet - from++; - onUpdate(db, from, to); - return; - } - if (from == 4) { - // table added in version 5 - onCreate(db); - from++; - onUpdate(db, from, to); - return; - } - if (from == 5) { - from++; - onUpdate(db, from, to); - return; - } - if (from == 17) { - db.execSQL("ALTER TABLE categories ADD COLUMN description STRING;"); - db.execSQL("ALTER TABLE categories ADD COLUMN thumbnail STRING;"); - from++; - onUpdate(db, from, to); - return; - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt new file mode 100644 index 0000000000..3371da1840 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDao.kt @@ -0,0 +1,194 @@ +package fr.free.nrw.commons.category + +import android.annotation.SuppressLint +import android.content.ContentProviderClient +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.os.RemoteException + +import java.util.ArrayList +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Provider + +class CategoryDao @Inject constructor( + @Named("category") private val clientProvider: Provider +) { + + fun save(category: Category) { + val db = clientProvider.get() + try { + if (category.contentUri == null) { + category.contentUri = db.insert( + CategoryContentProvider.BASE_URI, + toContentValues(category) + ) + } else { + db.update( + category.contentUri!!, + toContentValues(category), + null, + null + ) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + db.release() + } + } + + /** + * Find persisted category in database, based on its name. + * + * @param name Category's name + * @return category from database, or null if not found + */ + fun find(name: String): Category? { + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + CategoryContentProvider.BASE_URI, + ALL_FIELDS, + "${COLUMN_NAME}=?", + arrayOf(name), + null + ) + if (cursor != null && cursor.moveToFirst()) { + return fromCursor(cursor) + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return null + } + + /** + * Retrieve recently-used categories, ordered by descending date. + * + * @return a list containing recent categories + */ + fun recentCategories(limit: Int): List { + val items = ArrayList() + var cursor: Cursor? = null + val db = clientProvider.get() + try { + cursor = db.query( + CategoryContentProvider.BASE_URI, + ALL_FIELDS, + null, + emptyArray(), + "$COLUMN_LAST_USED DESC" + ) + while (cursor != null && cursor.moveToNext() && cursor.position < limit) { + val category = fromCursor(cursor) + if (category.name != null) { + items.add( + CategoryItem( + category.name, + category.description, + category.thumbnail, + false + ) + ) + } + } + } catch (e: RemoteException) { + throw RuntimeException(e) + } finally { + cursor?.close() + db.release() + } + return items + } + + @SuppressLint("Range") + fun fromCursor(cursor: Cursor): Category { + // Hardcoding column positions! + return Category( + CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))), + cursor.getString(cursor.getColumnIndex(COLUMN_NAME)), + cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)), + cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)), + Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))), + cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED)) + ) + } + + private fun toContentValues(category: Category): ContentValues { + return ContentValues().apply { + put(COLUMN_NAME, category.name) + put(COLUMN_DESCRIPTION, category.description) + put(COLUMN_THUMBNAIL, category.thumbnail) + put(COLUMN_LAST_USED, category.lastUsed?.time) + put(COLUMN_TIMES_USED, category.timesUsed) + } + } + + companion object Table { + const val TABLE_NAME = "categories" + + const val COLUMN_ID = "_id" + const val COLUMN_NAME = "name" + const val COLUMN_DESCRIPTION = "description" + const val COLUMN_THUMBNAIL = "thumbnail" + const val COLUMN_LAST_USED = "last_used" + const val COLUMN_TIMES_USED = "times_used" + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + val ALL_FIELDS = arrayOf( + COLUMN_ID, + COLUMN_NAME, + COLUMN_DESCRIPTION, + COLUMN_THUMBNAIL, + COLUMN_LAST_USED, + COLUMN_TIMES_USED + ) + + const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" + + "$COLUMN_ID INTEGER PRIMARY KEY," + + "$COLUMN_NAME STRING," + + "$COLUMN_DESCRIPTION STRING," + + "$COLUMN_THUMBNAIL STRING," + + "$COLUMN_LAST_USED INTEGER," + + "$COLUMN_TIMES_USED INTEGER" + + ");" + + @SuppressLint("SQLiteString") + fun onCreate(db: SQLiteDatabase) { + db.execSQL(CREATE_TABLE_STATEMENT) + } + + fun onDelete(db: SQLiteDatabase) { + db.execSQL(DROP_TABLE_STATEMENT) + onCreate(db) + } + + @SuppressLint("SQLiteString") + fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) { + if (from == to) return + if (from < 4) { + // doesn't exist yet + onUpdate(db, from + 1, to) + } else if (from == 4) { + // table added in version 5 + onCreate(db) + onUpdate(db, from + 1, to) + } else if (from == 5) { + onUpdate(db, from + 1, to) + } else if (from == 17) { + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;") + db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;") + onUpdate(db, from + 1, to) + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java deleted file mode 100644 index 457bd48c6a..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.java +++ /dev/null @@ -1,236 +0,0 @@ -package fr.free.nrw.commons.category; - -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.FrameLayout; -import androidx.appcompat.widget.Toolbar; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.viewpager.widget.ViewPager; -import com.google.android.material.tabs.TabLayout; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.ViewPagerAdapter; -import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding; -import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment; -import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment; -import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.theme.BaseActivity; -import java.util.ArrayList; -import java.util.List; -import fr.free.nrw.commons.wikidata.model.page.PageTitle; - -/** - * This activity displays details of a particular category - * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in - * a particular category on wikimedia commons. - */ - -public class CategoryDetailsActivity extends BaseActivity - implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback { - - - private FragmentManager supportFragmentManager; - private CategoriesMediaFragment categoriesMediaFragment; - private MediaDetailPagerFragment mediaDetails; - private String categoryName; - ViewPagerAdapter viewPagerAdapter; - - private ActivityCategoryDetailsBinding binding; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - binding = ActivityCategoryDetailsBinding.inflate(getLayoutInflater()); - final View view = binding.getRoot(); - setContentView(view); - supportFragmentManager = getSupportFragmentManager(); - viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager()); - binding.viewPager.setAdapter(viewPagerAdapter); - binding.viewPager.setOffscreenPageLimit(2); - binding.tabLayout.setupWithViewPager(binding.viewPager); - setSupportActionBar(binding.toolbarBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - setTabs(); - setPageTitle(); - } - - /** - * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, - * Set the fragments according to the tab selected in the viewPager. - */ - private void setTabs() { - List fragmentList = new ArrayList<>(); - List titleList = new ArrayList<>(); - categoriesMediaFragment = new CategoriesMediaFragment(); - SubCategoriesFragment subCategoryListFragment = new SubCategoriesFragment(); - ParentCategoriesFragment parentCategoriesFragment = new ParentCategoriesFragment(); - categoryName = getIntent().getStringExtra("categoryName"); - if (getIntent() != null && categoryName != null) { - Bundle arguments = new Bundle(); - arguments.putString("categoryName", categoryName); - categoriesMediaFragment.setArguments(arguments); - subCategoryListFragment.setArguments(arguments); - parentCategoriesFragment.setArguments(arguments); - } - fragmentList.add(categoriesMediaFragment); - titleList.add("MEDIA"); - fragmentList.add(subCategoryListFragment); - titleList.add("SUBCATEGORIES"); - fragmentList.add(parentCategoriesFragment); - titleList.add("PARENT CATEGORIES"); - viewPagerAdapter.setTabData(fragmentList, titleList); - viewPagerAdapter.notifyDataSetChanged(); - - } - - /** - * Gets the passed categoryName from the intents and displays it as the page title - */ - private void setPageTitle() { - if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) { - setTitle(getIntent().getStringExtra("categoryName")); - } - } - - /** - * This method is called onClick of media inside category details (CategoryImageListFragment). - */ - @Override - public void onMediaClicked(int position) { - binding.tabLayout.setVisibility(View.GONE); - binding.viewPager.setVisibility(View.GONE); - binding.mediaContainer.setVisibility(View.VISIBLE); - if (mediaDetails == null || !mediaDetails.isVisible()) { - // set isFeaturedImage true for featured images, to include author field on media detail - mediaDetails = MediaDetailPagerFragment.newInstance(false, true); - FragmentManager supportFragmentManager = getSupportFragmentManager(); - supportFragmentManager - .beginTransaction() - .replace(R.id.mediaContainer, mediaDetails) - .addToBackStack(null) - .commit(); - supportFragmentManager.executePendingTransactions(); - } - mediaDetails.showImage(position); - } - - - /** - * Consumers should be simply using this method to use this activity. - * @param context A Context of the application package implementing this class. - * @param categoryName Name of the category for displaying its details - */ - public static void startYourself(Context context, String categoryName) { - Intent intent = new Intent(context, CategoryDetailsActivity.class); - intent.putExtra("categoryName", categoryName); - context.startActivity(intent); - } - - /** - * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index - * @param i It is the index of which media object is to be returned which is same as - * current index of viewPager. - * @return Media Object - */ - @Override - public Media getMediaAtPosition(int i) { - return categoriesMediaFragment.getMediaAtPosition(i); - } - - /** - * This method is called on from getCount of MediaDetailPagerFragment - * The viewpager will contain same number of media items as that of media elements in adapter. - * @return Total Media count in the adapter - */ - @Override - public int getTotalMediaCount() { - return categoriesMediaFragment.getTotalMediaCount(); - } - - @Override - public Integer getContributionStateAt(int position) { - return null; - } - - /** - * Reload media detail fragment once media is nominated - * - * @param index item position that has been nominated - */ - @Override - public void refreshNominatedMedia(int index) { - if (getSupportFragmentManager().getBackStackEntryCount() == 1) { - onBackPressed(); - onMediaClicked(index); - } - } - - /** - * This method inflates the menu in the toolbar - */ - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.fragment_category_detail, menu); - return super.onCreateOptionsMenu(menu); - } - - /** - * This method handles the logic on ItemSelect in toolbar menu - * Currently only 1 choice is available to open category details page in browser - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - - // Handle item selection - switch (item.getItemId()) { - case R.id.menu_browser_current_category: - PageTitle title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName); - Utils.handleWebUrl(this, Uri.parse(title.getCanonicalUri())); - return true; - case android.R.id.home: - onBackPressed(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - /** - * This method is called on backPressed of anyFragment in the activity. - * If condition is called when mediaDetailFragment is opened. - */ - @Override - public void onBackPressed() { - if (supportFragmentManager.getBackStackEntryCount() == 1){ - binding.tabLayout.setVisibility(View.VISIBLE); - binding.viewPager.setVisibility(View.VISIBLE); - binding.mediaContainer.setVisibility(View.GONE); - } - super.onBackPressed(); - } - - /** - * This method is called on success of API call for Images inside a category. - * The viewpager will notified that number of items have changed. - */ - @Override - public void viewPagerNotifyDataSetChanged() { - if (mediaDetails!=null){ - mediaDetails.notifyDataSetChanged(); - } - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt new file mode 100644 index 0000000000..ba1fcfdae2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -0,0 +1,216 @@ +package fr.free.nrw.commons.category + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.ViewPagerAdapter +import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding +import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment +import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment +import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.theme.BaseActivity + + +/** + * This activity displays details of a particular category + * Its generic and simply takes the name of category name in its start intent to load all images, subcategories in + * a particular category on wikimedia commons. + */ +class CategoryDetailsActivity : BaseActivity(), + MediaDetailPagerFragment.MediaDetailProvider, + CategoryImagesCallback { + + private lateinit var supportFragmentManager: FragmentManager + private lateinit var categoriesMediaFragment: CategoriesMediaFragment + private var mediaDetails: MediaDetailPagerFragment? = null + private var categoryName: String? = null + private lateinit var viewPagerAdapter: ViewPagerAdapter + + private lateinit var binding: ActivityCategoryDetailsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityCategoryDetailsBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + supportFragmentManager = getSupportFragmentManager() + viewPagerAdapter = ViewPagerAdapter(supportFragmentManager) + binding.viewPager.adapter = viewPagerAdapter + binding.viewPager.offscreenPageLimit = 2 + binding.tabLayout.setupWithViewPager(binding.viewPager) + setSupportActionBar(binding.toolbarBinding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + setTabs() + setPageTitle() + } + + /** + * This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab, + * Set the fragments according to the tab selected in the viewPager. + */ + private fun setTabs() { + val fragmentList = mutableListOf() + val titleList = mutableListOf() + categoriesMediaFragment = CategoriesMediaFragment() + val subCategoryListFragment = SubCategoriesFragment() + val parentCategoriesFragment = ParentCategoriesFragment() + categoryName = intent?.getStringExtra("categoryName") + if (intent != null && categoryName != null) { + val arguments = Bundle().apply { + putString("categoryName", categoryName) + } + categoriesMediaFragment.arguments = arguments + subCategoryListFragment.arguments = arguments + parentCategoriesFragment.arguments = arguments + } + fragmentList.add(categoriesMediaFragment) + titleList.add("MEDIA") + fragmentList.add(subCategoryListFragment) + titleList.add("SUBCATEGORIES") + fragmentList.add(parentCategoriesFragment) + titleList.add("PARENT CATEGORIES") + viewPagerAdapter.setTabData(fragmentList, titleList) + viewPagerAdapter.notifyDataSetChanged() + } + + /** + * Gets the passed categoryName from the intents and displays it as the page title + */ + private fun setPageTitle() { + intent?.getStringExtra("categoryName")?.let { + title = it + } + } + + /** + * This method is called onClick of media inside category details (CategoryImageListFragment). + */ + override fun onMediaClicked(position: Int) { + binding.tabLayout.visibility = View.GONE + binding.viewPager.visibility = View.GONE + binding.mediaContainer.visibility = View.VISIBLE + if (mediaDetails == null || mediaDetails?.isVisible == false) { + // set isFeaturedImage true for featured images, to include author field on media detail + mediaDetails = MediaDetailPagerFragment.newInstance(false, true) + supportFragmentManager.beginTransaction() + .replace(R.id.mediaContainer, mediaDetails!!) + .addToBackStack(null) + .commit() + supportFragmentManager.executePendingTransactions() + } + mediaDetails?.showImage(position) + } + + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * @param context A Context of the application package implementing this class. + * @param categoryName Name of the category for displaying its details + */ + fun startYourself(context: Context?, categoryName: String) { + val intent = Intent(context, CategoryDetailsActivity::class.java).apply { + putExtra("categoryName", categoryName) + } + context?.startActivity(intent) + } + } + + /** + * This method is called mediaDetailPagerFragment. It returns the Media Object at that Index + * @param i It is the index of which media object is to be returned which is same as + * current index of viewPager. + * @return Media Object + */ + override fun getMediaAtPosition(i: Int): Media? { + return categoriesMediaFragment.getMediaAtPosition(i) + } + + /** + * This method is called on from getCount of MediaDetailPagerFragment + * The viewpager will contain same number of media items as that of media elements in adapter. + * @return Total Media count in the adapter + */ + override fun getTotalMediaCount(): Int { + return categoriesMediaFragment.getTotalMediaCount() + } + + override fun getContributionStateAt(position: Int): Int? { + return null + } + + /** + * Reload media detail fragment once media is nominated + * + * @param index item position that has been nominated + */ + override fun refreshNominatedMedia(index: Int) { + if (supportFragmentManager.backStackEntryCount == 1) { + onBackPressed() + onMediaClicked(index) + } + } + + /** + * This method inflates the menu in the toolbar + */ + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.fragment_category_detail, menu) + return super.onCreateOptionsMenu(menu) + } + + /** + * This method handles the logic on ItemSelect in toolbar menu + * Currently only 1 choice is available to open category details page in browser + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_browser_current_category -> { + val title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName) + Utils.handleWebUrl(this, Uri.parse(title.canonicalUri)) + true + } + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * This method is called on backPressed of anyFragment in the activity. + * If condition is called when mediaDetailFragment is opened. + */ + @Deprecated("This method has been deprecated in favor of using the" + + "{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." + + "The OnBackPressedDispatcher controls how back button events are dispatched" + + "to one or more {@link OnBackPressedCallback} objects.") + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount == 1) { + binding.tabLayout.visibility = View.VISIBLE + binding.viewPager.visibility = View.VISIBLE + binding.mediaContainer.visibility = View.GONE + } + super.onBackPressed() + } + + /** + * This method is called on success of API call for Images inside a category. + * The viewpager will notified that number of items have changed. + */ + override fun viewPagerNotifyDataSetChanged() { + mediaDetails?.notifyDataSetChanged() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java deleted file mode 100644 index 393a8dba49..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.java +++ /dev/null @@ -1,123 +0,0 @@ -package fr.free.nrw.commons.category; - -import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_CATEGORY; - -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.actions.PageEditClient; -import fr.free.nrw.commons.notification.NotificationHelper; -import fr.free.nrw.commons.utils.ViewUtilWrapper; -import io.reactivex.Observable; -import io.reactivex.Single; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -public class CategoryEditHelper { - private final NotificationHelper notificationHelper; - public final PageEditClient pageEditClient; - private final ViewUtilWrapper viewUtil; - private final String username; - - @Inject - public CategoryEditHelper(NotificationHelper notificationHelper, - @Named("commons-page-edit") PageEditClient pageEditClient, - ViewUtilWrapper viewUtil, - @Named("username") String username) { - this.notificationHelper = notificationHelper; - this.pageEditClient = pageEditClient; - this.viewUtil = viewUtil; - this.username = username; - } - - /** - * Public interface to edit categories - * @param context - * @param media - * @param categories - * @return - */ - public Single makeCategoryEdit(Context context, Media media, List categories, - final String wikiText) { - viewUtil.showShortToast(context, context.getString(R.string.category_edit_helper_make_edit_toast)); - return addCategory(media, categories, wikiText) - .flatMapSingle(result -> Single.just(showCategoryEditNotification(context, media, result))) - .firstOrError(); - } - - /** - * Rebuilds the WikiText with new categpries and post it on server - * - * @param media - * @param categories to be added - * @return - */ - private Observable addCategory(Media media, List categories, - final String wikiText) { - Timber.d("thread is category adding %s", Thread.currentThread().getName()); - String summary = "Adding categories"; - final StringBuilder buffer = new StringBuilder(); - final String wikiTextWithoutCategory; - //If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category" - if (wikiText.contains("Uncategorized")) { - wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("Uncategorized")); - } else if (wikiText.contains("[[Category")) { - wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("[[Category")); - } else { - wikiTextWithoutCategory = ""; - } - if (categories != null && !categories.isEmpty()) { - //If the categories list is empty, when reading the categories of a picture, - // the code will add "None selected" to categories list in order to see in picture's categories with "None selected". - // So that after selected some category,"None selected" should be removed from list - for (int i = 0; i < categories.size(); i++) { - if (!categories.get(i).equals("None selected")//Not to add "None selected" as category to wikiText - || !wikiText.contains("Uncategorized")) { - buffer.append("[[Category:").append(categories.get(i)).append("]]\n"); - } - } - categories.remove("None selected"); - } else { - buffer.append("{{subst:unc}}"); - } - final String appendText = wikiTextWithoutCategory + buffer; - return pageEditClient.edit(media.getFilename(), appendText + "\n", summary); - } - - private boolean showCategoryEditNotification(Context context, Media media, boolean result) { - String message; - String title = context.getString(R.string.category_edit_helper_show_edit_title); - - if (result) { - title += ": " + context.getString(R.string.category_edit_helper_show_edit_title_success); - StringBuilder categoriesInMessage = new StringBuilder(); - List mediaCategoryList = media.getCategories(); - for (String category : mediaCategoryList) { - categoriesInMessage.append(category); - if (category.equals(mediaCategoryList.get(mediaCategoryList.size()-1))) { - continue; - } - categoriesInMessage.append(","); - } - - message = context.getResources().getQuantityString(R.plurals.category_edit_helper_show_edit_message_if, mediaCategoryList.size(), categoriesInMessage.toString()); - } else { - title += ": " + context.getString(R.string.category_edit_helper_show_edit_title); - message = context.getString(R.string.category_edit_helper_edit_message_else) ; - } - - String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename(); - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)); - notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_CATEGORY, browserIntent); - return result; - } - - public interface Callback { - boolean updateCategoryDisplay(List categories); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt new file mode 100644 index 0000000000..22cb191723 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryEditHelper.kt @@ -0,0 +1,144 @@ +package fr.free.nrw.commons.category + +import android.content.Context +import android.content.Intent +import android.net.Uri +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.actions.PageEditClient +import fr.free.nrw.commons.notification.NotificationHelper +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Observable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Named +import timber.log.Timber + + +class CategoryEditHelper @Inject constructor( + private val notificationHelper: NotificationHelper, + @Named("commons-page-edit") val pageEditClient: PageEditClient, + private val viewUtil: ViewUtilWrapper, + @Named("username") private val username: String +) { + + /** + * Public interface to edit categories + * @param context + * @param media + * @param categories + * @return + */ + fun makeCategoryEdit( + context: Context, + media: Media, + categories: List, + wikiText: String + ): Single { + viewUtil.showShortToast( + context, + context.getString(R.string.category_edit_helper_make_edit_toast) + ) + return addCategory(media, categories, wikiText) + .flatMapSingle { result -> + Single.just(showCategoryEditNotification(context, media, result)) + } + .firstOrError() + } + + /** + * Rebuilds the WikiText with new categories and post it on server + * + * @param media + * @param categories to be added + * @return + */ + private fun addCategory( + media: Media, + categories: List?, + wikiText: String + ): Observable { + Timber.d("thread is category adding %s", Thread.currentThread().name) + val summary = "Adding categories" + val buffer = StringBuilder() + + // If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category" + val wikiTextWithoutCategory: String = when { + wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized")) + wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category")) + else -> "" + } + + if (!categories.isNullOrEmpty()) { + // If the categories list is empty, when reading the categories of a picture, + // the code will add "None selected" to categories list in order to see in picture's categories with "None selected". + // So that after selecting some category, "None selected" should be removed from list + for (category in categories) { + if (category != "None selected" || !wikiText.contains("Uncategorized")) { + buffer.append("[[Category:").append(category).append("]]\n") + } + } + categories.dropWhile { + it == "None selected" + } + } else { + buffer.append("{{subst:unc}}") + } + + val appendText = wikiTextWithoutCategory + buffer + return pageEditClient.edit(media.filename!!, "$appendText\n", summary) + } + + private fun showCategoryEditNotification( + context: Context, + media: Media, + result: Boolean + ): Boolean { + val title: String + val message: String + + if (result) { + title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + + context.getString(R.string.category_edit_helper_show_edit_title_success) + + val categoriesInMessage = StringBuilder() + val mediaCategoryList = media.categories + for ((index, category) in mediaCategoryList?.withIndex()!!) { + categoriesInMessage.append(category) + if (index != mediaCategoryList.size - 1) { + categoriesInMessage.append(",") + } + } + + message = context.resources.getQuantityString( + R.plurals.category_edit_helper_show_edit_message_if, + mediaCategoryList.size, + categoriesInMessage.toString() + ) + } else { + title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " + + context.getString(R.string.category_edit_helper_show_edit_title) + message = context.getString(R.string.category_edit_helper_edit_message_else) + } + + val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}" + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile)) + notificationHelper.showNotification( + context, + title, + message, + NOTIFICATION_EDIT_CATEGORY, + browserIntent + ) + return result + } + + interface Callback { + fun updateCategoryDisplay(categories: List?): Boolean + } + + companion object { + const val NOTIFICATION_EDIT_CATEGORY = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java deleted file mode 100644 index 5b85a2f814..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.java +++ /dev/null @@ -1,13 +0,0 @@ -package fr.free.nrw.commons.category; - -/** - * Callback for notifying the viewpager that the number of items have changed - * and for requesting more images when the viewpager has been scrolled to its end. - */ - -public interface CategoryImagesCallback { - void viewPagerNotifyDataSetChanged(); - void onMediaClicked(int position); -} - - diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt new file mode 100644 index 0000000000..9fe811f74e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryImagesCallback.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.category + +interface CategoryImagesCallback { + fun viewPagerNotifyDataSetChanged() + + fun onMediaClicked(position: Int) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java deleted file mode 100644 index af28ad07d1..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.java +++ /dev/null @@ -1,119 +0,0 @@ -package fr.free.nrw.commons.category; - -import android.content.Context; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.TextView; - -import androidx.annotation.Nullable; - -import com.facebook.drawee.view.SimpleDraweeView; - -import java.util.ArrayList; -import java.util.List; - -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; - -/** - * This is created to only display UI implementation. Needs to be changed in real implementation - */ - -public class GridViewAdapter extends ArrayAdapter { - private List data; - - public GridViewAdapter(Context context, int layoutResourceId, List data) { - super(context, layoutResourceId, data); - this.data = data; - } - - /** - * Adds more item to the list - * Its triggered on scrolling down in the list - * @param images - */ - public void addItems(List images) { - if (data == null) { - data = new ArrayList<>(); - } - data.addAll(images); - notifyDataSetChanged(); - } - - /** - * Check the first item in the new list with old list and returns true if they are same - * Its triggered on successful response of the fetch images API. - * @param images - */ - public boolean containsAll(List images){ - if (images == null || images.isEmpty()) { - return false; - } - if (data == null) { - data = new ArrayList<>(); - return false; - } - if (data.isEmpty()) { - return false; - } - String fileName = data.get(0).getFilename(); - String imageName = images.get(0).getFilename(); - return imageName.equals(fileName); - } - - @Override - public boolean isEmpty() { - return data == null || data.isEmpty(); - } - - /** - * Sets up the UI for the category image item - * @param position - * @param convertView - * @param parent - * @return - */ - @Override - public View getView(int position, View convertView, ViewGroup parent) { - - if (convertView == null) { - convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_category_images, null); - } - - Media item = data.get(position); - SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView); - TextView fileName = convertView.findViewById(R.id.categoryImageTitle); - TextView uploader = convertView.findViewById(R.id.categoryImageAuthor); - fileName.setText(item.getMostRelevantCaption()); - setUploaderView(item, uploader); - imageView.setImageURI(item.getThumbUrl()); - return convertView; - } - - /** - * @return the Media item at the given position - */ - @Nullable - @Override - public Media getItem(int position) { - return data.get(position); - } - - - /** - * Shows author information if its present - * @param item - * @param uploader - */ - private void setUploaderView(Media item, TextView uploader) { - if (!TextUtils.isEmpty(item.getAuthor())) { - uploader.setVisibility(View.VISIBLE); - uploader.setText(getContext().getString(R.string.image_uploaded_by, item.getUser())); - } else { - uploader.setVisibility(View.GONE); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt new file mode 100644 index 0000000000..5dbcc59fda --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/GridViewAdapter.kt @@ -0,0 +1,111 @@ +package fr.free.nrw.commons.category + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R + + +/** + * This is created to only display UI implementation. Needs to be changed in real implementation + */ +class GridViewAdapter( + context: Context, + layoutResourceId: Int, + private var data: MutableList? +) : ArrayAdapter(context, layoutResourceId, data ?: mutableListOf()) { + + /** + * Adds more items to the list + * It's triggered on scrolling down in the list + * @param images + */ + fun addItems(images: List) { + if (data == null) { + data = mutableListOf() + } + data?.addAll(images) + notifyDataSetChanged() + } + + /** + * Checks the first item in the new list with the old list and returns true if they are the same + * It's triggered on a successful response of the fetch images API. + * @param images + */ + fun containsAll(images: List?): Boolean { + if (images.isNullOrEmpty()) { + return false + } + if (data.isNullOrEmpty()) { + data = mutableListOf() + return false + } + val fileName = data?.get(0)?.filename + val imageName = images[0].filename + return imageName == fileName + } + + override fun isEmpty(): Boolean { + return data.isNullOrEmpty() + } + + /** + * Sets up the UI for the category image item + * @param position + * @param convertView + * @param parent + * @return + */ + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate( + R.layout.layout_category_images, + parent, + false + ) + + val item = data?.get(position) + val imageView = view.findViewById(R.id.categoryImageView) + val fileName = view.findViewById(R.id.categoryImageTitle) + val uploader = view.findViewById(R.id.categoryImageAuthor) + + item?.let { + fileName.text = it.mostRelevantCaption + setUploaderView(it, uploader) + imageView.setImageURI(it.thumbUrl) + } + + return view + } + + /** + * @return the Media item at the given position + */ + override fun getItem(position: Int): Media? { + return data?.get(position) + } + + /** + * Shows author information if it's present + * @param item + * @param uploader + */ + @SuppressLint("StringFormatInvalid") + private fun setUploaderView(item: Media, uploader: TextView) { + if (!item.author.isNullOrEmpty()) { + uploader.visibility = View.VISIBLE + uploader.text = context.getString( + R.string.image_uploaded_by, + item.user + ) + } else { + uploader.visibility = View.GONE + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java deleted file mode 100644 index 5899d59051..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.category; - -import java.util.List; - -public interface OnCategoriesSaveHandler { - void onCategoriesSave(List categories); -} diff --git a/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt new file mode 100644 index 0000000000..68200992c7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/OnCategoriesSaveHandler.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.category + +interface OnCategoriesSaveHandler { + fun onCategoriesSave(categories: List) +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragmentUnitTests.kt index 301e31d6da..03de2638d7 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/pictures/BookmarkPicturesFragmentUnitTests.kt @@ -108,7 +108,7 @@ class BookmarkPicturesFragmentUnitTests { GridViewAdapter( context, 0, - listOf(media()), + mutableListOf(media()), ), ) Whitebox.setInternalState(fragment, "binding", binding) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDaoTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDaoTest.kt index ab222b4ebe..e93f48c55b 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDaoTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDaoTest.kt @@ -17,8 +17,6 @@ import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import fr.free.nrw.commons.TestCommonsApplication -import fr.free.nrw.commons.category.CategoryContentProvider.BASE_URI -import fr.free.nrw.commons.category.CategoryContentProvider.uriForId import fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS import fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_DESCRIPTION import fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID @@ -31,6 +29,7 @@ import fr.free.nrw.commons.category.CategoryDao.Table.DROP_TABLE_STATEMENT import fr.free.nrw.commons.category.CategoryDao.Table.onCreate import fr.free.nrw.commons.category.CategoryDao.Table.onDelete import fr.free.nrw.commons.category.CategoryDao.Table.onUpdate +import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider.uriForId import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -85,21 +84,21 @@ class CategoryDaoTest { @Test fun migrateTableVersionFrom_v1_to_v2() { onUpdate(database, 1, 2) - // Table didnt exist before v5 + // Table didn't exist before v5 verifyNoInteractions(database) } @Test fun migrateTableVersionFrom_v2_to_v3() { onUpdate(database, 2, 3) - // Table didnt exist before v5 + // Table didn't exist before v5 verifyNoInteractions(database) } @Test fun migrateTableVersionFrom_v3_to_v4() { onUpdate(database, 3, 4) - // Table didnt exist before v5 + // Table didn't exist before v5 verifyNoInteractions(database) } @@ -112,21 +111,21 @@ class CategoryDaoTest { @Test fun migrateTableVersionFrom_v5_to_v6() { onUpdate(database, 5, 6) - // Table didnt change in version 6 + // Table didn't change in version 6 verifyNoInteractions(database) } @Test fun migrateTableVersionFrom_v6_to_v7() { onUpdate(database, 6, 7) - // Table didnt change in version 7 + // Table didn't change in version 7 verifyNoInteractions(database) } @Test fun migrateTableVersionFrom_v7_to_v8() { onUpdate(database, 7, 8) - // Table didnt change in version 8 + // Table didn't change in version 8 verifyNoInteractions(database) } @@ -135,9 +134,9 @@ class CategoryDaoTest { createCursor(1).let { cursor -> cursor.moveToFirst() testObject.fromCursor(cursor).let { - assertEquals(uriForId(1), it.contentUri) + assertEquals(CategoryContentProvider.uriForId(1), it.contentUri) assertEquals("showImageWithItem", it.name) - assertEquals(123, it.lastUsed.time) + assertEquals(123L, it.lastUsed?.time) assertEquals(2, it.timesUsed) } } @@ -150,13 +149,18 @@ class CategoryDaoTest { testObject.save(category) - verify(client).update(eq(category.contentUri), captor.capture(), isNull(), isNull()) + verify(client).update( + eq(category.contentUri)!!, + captor.capture(), + isNull(), + isNull() + ) captor.firstValue.let { cv -> assertEquals(5, cv.size()) assertEquals(category.name, cv.getAsString(COLUMN_NAME)) assertEquals(category.description, cv.getAsString(COLUMN_DESCRIPTION)) assertEquals(category.thumbnail, cv.getAsString(COLUMN_THUMBNAIL)) - assertEquals(category.lastUsed.time, cv.getAsLong(COLUMN_LAST_USED)) + assertEquals(category.lastUsed?.time, cv.getAsLong(COLUMN_LAST_USED)) assertEquals(category.timesUsed, cv.getAsInteger(COLUMN_TIMES_USED)) } } @@ -164,7 +168,7 @@ class CategoryDaoTest { @Test fun saveNewCategory() { - val contentUri = CategoryContentProvider.uriForId(111) + val contentUri = uriForId(111) whenever(client.insert(isA(), isA())).thenReturn(contentUri) val category = Category( @@ -178,13 +182,13 @@ class CategoryDaoTest { testObject.save(category) - verify(client).insert(eq(BASE_URI), captor.capture()) + verify(client).insert(eq(CategoryContentProvider.BASE_URI), captor.capture()) captor.firstValue.let { cv -> assertEquals(5, cv.size()) assertEquals(category.name, cv.getAsString(COLUMN_NAME)) assertEquals(category.description, cv.getAsString(COLUMN_DESCRIPTION)) assertEquals(category.thumbnail, cv.getAsString(COLUMN_THUMBNAIL)) - assertEquals(category.lastUsed.time, cv.getAsLong(COLUMN_LAST_USED)) + assertEquals(category.lastUsed?.time, cv.getAsLong(COLUMN_LAST_USED)) assertEquals(category.timesUsed, cv.getAsInteger(COLUMN_TIMES_USED)) assertEquals(contentUri, category.contentUri) } @@ -226,7 +230,7 @@ class CategoryDaoTest { val category = testObject.find("showImageWithItem") assertNotNull(category) - assertEquals(uriForId(1), category?.contentUri) + assertEquals(CategoryContentProvider.uriForId(1), category?.contentUri) assertEquals("showImageWithItem", category?.name) assertEquals("description", category?.description) assertEquals("image", category?.thumbnail) @@ -234,7 +238,7 @@ class CategoryDaoTest { assertEquals(2, category?.timesUsed) verify(client).query( - eq(BASE_URI), + eq(CategoryContentProvider.BASE_URI), eq(ALL_FIELDS), eq("$COLUMN_NAME=?"), queryCaptor.capture(), @@ -288,7 +292,7 @@ class CategoryDaoTest { assertEquals("showImageWithItem", result[0].name) verify(client).query( - eq(BASE_URI), + eq(CategoryContentProvider.BASE_URI), eq(ALL_FIELDS), isNull(), queryCaptor.capture(), diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt index d3901113a0..6fb400f3e8 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/category/GridViewAdapterUnitTest.kt @@ -38,7 +38,7 @@ class GridViewAdapterUnitTest { private lateinit var parent: ViewGroup @Mock - private lateinit var images: List + private lateinit var images: MutableList @Mock private lateinit var textView: TextView @@ -82,20 +82,20 @@ class GridViewAdapterUnitTest { @Test fun testContainsAllDataEmpty() { - gridViewAdapter = GridViewAdapter(context, 0, listOf()) + gridViewAdapter = GridViewAdapter(context, 0, mutableListOf()) Assert.assertEquals(gridViewAdapter.containsAll(images), false) } @Test fun testContainsAll() { - gridViewAdapter = GridViewAdapter(context, 0, listOf(media1)) + gridViewAdapter = GridViewAdapter(context, 0, mutableListOf(media1)) `when`(media1.filename).thenReturn("") Assert.assertEquals(gridViewAdapter.containsAll(listOf(media1)), true) } @Test fun testGetItem() { - gridViewAdapter = GridViewAdapter(context, 0, listOf(media1)) + gridViewAdapter = GridViewAdapter(context, 0, mutableListOf(media1)) Assert.assertEquals(gridViewAdapter.getItem(0), media1) } @@ -107,7 +107,7 @@ class GridViewAdapterUnitTest { @Test fun testGetView() { - gridViewAdapter = GridViewAdapter(context, 0, listOf(media1)) + gridViewAdapter = GridViewAdapter(context, 0, mutableListOf(media1)) `when`(media1.mostRelevantCaption).thenReturn("") Assert.assertEquals(gridViewAdapter.getView(0, convertView, parent), convertView) }