Merge branch 'main' into jetpack-custom-selector

This commit is contained in:
Rohit Verma 2024-11-27 10:39:37 +05:30 committed by GitHub
commit 4ebb945308
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
256 changed files with 7222 additions and 6600 deletions

View file

@ -100,12 +100,12 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION"
//Mocking
testImplementation("io.mockk:mockk:1.13.4")
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito:mockito-core:5.6.0'
testImplementation "org.powermock:powermock-module-junit4:2.0.9"
testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
testImplementation("io.mockk:mockk:1.13.5")
// Unit testing
testImplementation 'junit:junit:4.13.2'
@ -374,11 +374,11 @@ android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
}
buildToolsVersion buildToolsVersion

View file

@ -367,7 +367,7 @@ public class LocationPickerActivity extends BaseActivity implements
*/
private void removeLocationFromImage() {
if (media != null) {
compositeDisposable.add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext()
getCompositeDisposable().add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext()
, media, "0.0", "0.0", "0.0f")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@ -479,7 +479,7 @@ public class LocationPickerActivity extends BaseActivity implements
}
try {
compositeDisposable.add(
getCompositeDisposable().add(
coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media,
Latitude, Longitude, Accuracy)
.subscribeOn(Schedulers.io())

View file

@ -12,15 +12,15 @@ import androidx.annotation.Nullable;
import fr.free.nrw.commons.campaigns.models.Campaign;
import fr.free.nrw.commons.databinding.LayoutCampaginBinding;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DateUtil;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.utils.DateUtil;
import java.text.ParseException;
import java.util.Date;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.utils.SwipableCardView;
import fr.free.nrw.commons.utils.ViewUtil;

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.campaigns;
import android.annotation.SuppressLint;
import fr.free.nrw.commons.campaigns.models.Campaign;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import java.text.ParseException;
import java.util.Collections;
import java.util.Date;
@ -14,7 +15,6 @@ import javax.inject.Singleton;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import io.reactivex.Scheduler;
import io.reactivex.Single;
import io.reactivex.SingleObserver;

View file

@ -124,7 +124,9 @@ class CategoryClient
}.map {
it
.filter { page ->
!page.categoryInfo().isHidden
// Null check is not redundant because some values could be null
// for mocks when running unit tests
page.categoryInfo()?.isHidden != true
}.map {
CategoryItem(
it.title().replace(CATEGORY_PREFIX, ""),

View file

@ -87,7 +87,7 @@ public class ContributionController {
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE);
PermissionUtils.getPERMISSIONS_STORAGE());
}
/**
@ -224,7 +224,7 @@ public class ContributionController {
() -> FilePicker.openCustomSelector(activity, resultLauncher, 0),
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE);
PermissionUtils.getPERMISSIONS_STORAGE());
}

View file

@ -5,7 +5,6 @@ import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED;
import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED;
import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL;
import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import static fr.free.nrw.commons.utils.LengthUtils.computeBearing;
import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween;
@ -23,12 +22,10 @@ import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback;
@ -39,7 +36,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
import androidx.fragment.app.FragmentTransaction;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.databinding.FragmentContributionsBinding;
@ -293,7 +289,7 @@ public class ContributionsFragment
});
}
notification.setOnClickListener(view -> {
NotificationActivity.startYourself(getContext(), "unread");
NotificationActivity.Companion.startYourself(getContext(), "unread");
});
}

View file

@ -1,13 +1,10 @@
package fr.free.nrw.commons.contributions;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
@ -16,10 +13,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.databinding.MainBinding;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.auth.SessionManager;
@ -41,7 +36,6 @@ import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.PermissionUtils;
@ -420,7 +414,7 @@ public class MainActivity extends BaseActivity
return true;
case R.id.notifications:
// Starts notification activity on click to notification icon
NotificationActivity.startYourself(this, "unread");
NotificationActivity.Companion.startYourself(this, "unread");
return true;
default:
return super.onOptionsItemSelected(item);

View file

@ -0,0 +1,249 @@
package fr.free.nrw.commons.customselector.helper
import android.content.ContentUris
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.appcompat.app.AlertDialog
import fr.free.nrw.commons.R
import timber.log.Timber
import java.io.File
object FolderDeletionHelper {
/**
* Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it.
*
* @param context The context used to show the confirmation dialog and manage deletion.
* @param folder The folder to be deleted.
* @param onDeletionComplete Callback invoked with `true` if the folder was
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
* successfully deleted, `false` otherwise.
*/
fun confirmAndDeleteFolder(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>,
onDeletionComplete: (Boolean) -> Unit) {
val itemCount = countItemsInFolder(context, folder)
val folderPath = folder.absolutePath
//don't show this dialog on API 30+, it's handled automatically using MediaStore
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val success = deleteFolderMain(context, folder, trashFolderLauncher)
onDeletionComplete(success)
} else {
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
.setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount))
.setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
//proceed with deletion if user confirms
val success = deleteFolderMain(context, folder, trashFolderLauncher)
onDeletionComplete(success)
}
.setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ ->
dialog.dismiss()
onDeletionComplete(false)
}
.show()
}
}
/**
* Deletes the specified folder, handling different Android storage models based on the API
*
* @param context The context used to manage storage operations.
* @param folder The folder to delete.
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
* @return `true` if the folder deletion was successful, `false` otherwise.
*/
private fun deleteFolderMain(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
{
return when {
//for API 30 and above, use MediaStore
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder, trashFolderLauncher)
//for API 29 ('requestLegacyExternalStorage' is set to true in Manifest)
// and below use file system
else -> deleteFolderLegacy(folder)
}
}
/**
* Moves all contents of a specified folder to the trash on devices running
* Android 11 (API level 30) and above.
*
* @param context The context used to access the content resolver.
* @param folder The folder whose contents are to be moved to the trash.
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
* @return `true` if the trash request was initiated successfully, `false` otherwise.
*/
private fun trashFolderContents(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
val contentResolver = context.contentResolver
val folderPath = folder.absolutePath
val urisToTrash = mutableListOf<Uri>()
// Use URIs specific to media items
val mediaUris = listOf(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
)
for (mediaUri in mediaUris) {
val selection = "${MediaStore.MediaColumns.DATA} LIKE ?"
val selectionArgs = arrayOf("$folderPath/%")
contentResolver.query(mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
selectionArgs, null)
?.use{ cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val fileUri = ContentUris.withAppendedId(mediaUri, id)
urisToTrash.add(fileUri)
}
}
}
//proceed with trashing if we have valid URIs
if (urisToTrash.isNotEmpty()) {
try {
val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true)
val intentSenderRequest = IntentSenderRequest.Builder(trashRequest.intentSender).build()
trashFolderLauncher.launch(intentSenderRequest)
return true
} catch (e: SecurityException) {
Timber.tag("DeleteFolder").e(context.getString(R.string.custom_selector_error_trashing_folder_contents, e.message))
}
}
return false
}
/**
* Counts the number of items in a specified folder, including items in subfolders.
*
* @param context The context used to access the content resolver.
* @param folder The folder in which to count items.
* @return The total number of items in the folder.
*/
private fun countItemsInFolder(context: Context, folder: File): Int {
val contentResolver = context.contentResolver
val folderPath = folder.absolutePath
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = "${MediaStore.Images.Media.DATA} LIKE ?"
val selectionArgs = arrayOf("$folderPath/%")
return contentResolver.query(
uri,
arrayOf(MediaStore.Images.Media._ID),
selection,
selectionArgs,
null)?.use { cursor ->
cursor.count
} ?: 0
}
/**
* Refreshes the MediaStore for a specified folder, updating the system to recognize any changes
*
* @param context The context used to access the MediaScannerConnection.
* @param folder The folder to refresh in the MediaStore.
*/
fun refreshMediaStore(context: Context, folder: File) {
MediaScannerConnection.scanFile(
context,
arrayOf(folder.absolutePath),
null
) { _, _ -> }
}
/**
* Deletes a specified folder and all of its contents on devices running
* Android 10 (API level 29) and below.
*
* @param folder The `File` object representing the folder to be deleted.
* @return `true` if the folder and all contents were deleted successfully; `false` otherwise.
*/
private fun deleteFolderLegacy(folder: File): Boolean {
return folder.deleteRecursively()
}
/**
* Retrieves the absolute path of a folder given its unique identifier (bucket ID).
*
* @param context The context used to access the content resolver.
* @param folderId The unique identifier (bucket ID) of the folder.
* @return The absolute path of the folder as a `String`, or `null` if the folder is not found.
*/
fun getFolderPath(context: Context, folderId: Long): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?"
val selectionArgs = arrayOf(folderId.toString())
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
return File(fullPath).parent
}
}
return null
}
/**
* Displays an error message to the user and logs it for debugging purposes.
*
* @param context The context used to display the Toast.
* @param message The error message to display and log.
* @param folderName The name of the folder to delete.
*/
fun showError(context: Context, message: String, folderName: String) {
Toast.makeText(context,
context.getString(R.string.custom_selector_folder_deleted_failure, folderName),
Toast.LENGTH_SHORT).show()
Timber.tag("DeleteFolder").e(message)
}
/**
* Displays a success message to the user.
*
* @param context The context used to display the Toast.
* @param message The success message to display.
* @param folderName The name of the folder to delete.
*/
fun showSuccess(context: Context, message: String, folderName: String) {
Toast.makeText(context,
context.getString(R.string.custom_selector_folder_deleted_success, folderName),
Toast.LENGTH_SHORT).show()
Timber.tag("DeleteFolder").d(message)
}
}

View file

@ -13,11 +13,26 @@ import android.view.View
import android.view.Window
import android.widget.Button
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -34,6 +49,7 @@ import fr.free.nrw.commons.customselector.data.MediaReader
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image
@ -136,10 +152,23 @@ class CustomSelectorActivity :
private var showPartialAccessIndicator by mutableStateOf(false)
private val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ result ->
/**
* Show delete button in folder
*/
private var showOverflowMenu = false
/**
* Waits for confirmation of delete folder
*/
private val startForFolderDeletionResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()){
result -> onDeleteFolderResultReceived(result)
}
private val startForResult = registerForActivityResult(StartActivityForResult()){ result ->
onFullScreenDataReceived(result)
}
/**
* onCreate Activity, sets theme, initialises the view model, setup view.
*/
@ -264,6 +293,15 @@ class CustomSelectorActivity :
}
}
private fun onDeleteFolderResultReceived(result: ActivityResult){
if (result.resultCode == Activity.RESULT_OK){
FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
navigateToCustomSelector()
}
}
/**
* Show Custom Selector Welcome Dialog.
*/
@ -445,10 +483,97 @@ class CustomSelectorActivity :
val limitError: ImageButton = findViewById(R.id.image_limit_error)
limitError.visibility = View.INVISIBLE
limitError.setOnClickListener { displayUploadLimitWarning() }
val overflowMenu: ImageButton = findViewById(R.id.menu_overflow)
if(defaultKvStore.getBoolean("displayDeletionButton")) {
overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE
overflowMenu.setOnClickListener { showPopupMenu(overflowMenu) }
}else{
overflowMenu.visibility = View.GONE
}
}
private fun showPopupMenu(anchorView: View) {
val popupMenu = PopupMenu(this, anchorView)
popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_delete_folder -> {
deleteFolder()
true
}
else -> false
}
}
popupMenu.show()
}
/**
* override on folder click, change the toolbar title on folder click.
* Deletes folder based on Android API version.
*/
private fun deleteFolder() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: run {
FolderDeletionHelper.showError(this, "Failed to retrieve folder path", bucketName)
return
}
val folder = File(folderPath)
if (!folder.exists() || !folder.isDirectory) {
FolderDeletionHelper.showError(this,"Folder not found or is not a directory", bucketName)
return
}
FolderDeletionHelper.confirmAndDeleteFolder(this, folder, startForFolderDeletionResult) { success ->
if (success) {
//for API 30+, navigation is handled in 'onDeleteFolderResultReceived'
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
navigateToCustomSelector()
}
} else {
FolderDeletionHelper.showError(this, "Failed to delete folder", bucketName)
}
}
}
/**
* Navigates back to the main `FolderFragment`, refreshes the MediaStore, resets UI states,
* and reloads folder data.
*/
private fun navigateToCustomSelector() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: ""
val folder = File(folderPath)
supportFragmentManager.popBackStack(null,
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE)
//refresh MediaStore for the deleted folder path to ensure metadata updates
FolderDeletionHelper.refreshMediaStore(this, folder)
//replace the current fragment with FolderFragment to go back to the main screen
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance())
.commitAllowingStateLoss()
//reset toolbar and flags
isImageFragmentOpen = false
showOverflowMenu = false
setUpToolbar()
changeTitle(getString(R.string.custom_selector_title), 0)
//fetch updated folder data
fetchData()
}
/**
* override on folder click,
* change the toolbar title on folder click, make overflow menu visible
*/
override fun onFolderClick(
folderId: Long,
@ -466,6 +591,11 @@ class CustomSelectorActivity :
bucketId = folderId
bucketName = folderName
isImageFragmentOpen = true
//show the overflow menu only when a folder is clicked
showOverflowMenu = true
setUpToolbar()
}
/**
@ -581,6 +711,10 @@ class CustomSelectorActivity :
isImageFragmentOpen = false
changeTitle(getString(R.string.custom_selector_title), 0)
}
//hide overflow menu when not in folder
showOverflowMenu = false
setUpToolbar()
}
/**

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.os.Bundle
@ -346,7 +347,7 @@ class ImageFragment :
context
.getSharedPreferences(
"CustomSelector",
BaseActivity.MODE_PRIVATE,
MODE_PRIVATE,
)?.let { prefs ->
prefs.edit()?.let { editor ->
editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply()

View file

@ -18,6 +18,7 @@ import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileE
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.concurrent.TimeUnit
@ -65,7 +66,7 @@ class ImageLoader
/**
* Coroutine Scope.
*/
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private val scope: CoroutineScope = MainScope()
/**
* Query image and setUp the view.

View file

@ -1,10 +1,10 @@
package fr.free.nrw.commons.delete;
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_DELETE;
import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources;
import android.annotation.SuppressLint;
import android.content.Context;
import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
@ -16,6 +16,7 @@ import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.utils.LangCodeUtils;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Observable;
import io.reactivex.Single;

View file

@ -3,7 +3,6 @@ package fr.free.nrw.commons.delete;
import android.content.Context;
import fr.free.nrw.commons.utils.DateUtil;
import java.util.Date;
import java.util.Locale;

View file

@ -2,7 +2,6 @@ package fr.free.nrw.commons.di;
import com.google.gson.Gson;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.explore.categories.CategoriesModule;
import fr.free.nrw.commons.navtab.MoreBottomSheetFragment;
import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment;

View file

@ -11,7 +11,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.viewpager.widget.ViewPager.OnPageChangeListener;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.contributions.MainActivity;

View file

@ -104,7 +104,7 @@ public class SearchActivity extends BaseActivity
viewPagerAdapter.setTabData(fragmentList, titleList);
viewPagerAdapter.notifyDataSetChanged();
compositeDisposable.add(RxSearchView.queryTextChanges(binding.searchBox)
getCompositeDisposable().add(RxSearchView.queryTextChanges(binding.searchBox)
.takeUntil(RxView.detaches(binding.searchBox))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
@ -284,7 +284,7 @@ public class SearchActivity extends BaseActivity
@Override protected void onDestroy() {
super.onDestroy();
//Dispose the disposables when the activity is destroyed
compositeDisposable.dispose();
getCompositeDisposable().dispose();
binding = null;
}
}

View file

@ -4,14 +4,12 @@ import static fr.free.nrw.commons.location.LocationServiceManager.LocationChange
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SLIGHTLY_CHANGED;
import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL;
import android.Manifest;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Paint;
@ -21,22 +19,17 @@ import android.location.Location;
import android.location.LocationManager;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.provider.Settings;
import android.text.Html;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.MapController;
@ -48,7 +41,6 @@ import fr.free.nrw.commons.databinding.FragmentExploreMapBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.explore.ExploreMapRootFragment;
import fr.free.nrw.commons.explore.paging.LiveDataConverter;
import fr.free.nrw.commons.filepicker.Constants;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
@ -60,7 +52,6 @@ import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.MapUtils;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable;
@ -310,7 +301,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
}
private void startMapWithoutPermission() {
lastKnownLocation = MapUtils.defaultLatLng;
lastKnownLocation = MapUtils.getDefaultLatLng();
moveCameraToPosition(
new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude()));
presenter.onMapReady(exploreMapController);
@ -331,7 +322,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
!locationPermissionsHelper.checkLocationPermission(getActivity())) {
isPermissionDenied = true;
}
lastKnownLocation = MapUtils.defaultLatLng;
lastKnownLocation = MapUtils.getDefaultLatLng();
moveCameraToPosition(
new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude()));
presenter.onMapReady(exploreMapController);

View file

@ -55,7 +55,6 @@ import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.actions.ThanksClient;
import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.category.CategoryClient;
import fr.free.nrw.commons.category.CategoryDetailsActivity;
@ -272,6 +271,12 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment implements
if (!sessionManager.isUserLoggedIn()) {
binding.categoryEditButton.setVisibility(GONE);
binding.descriptionEdit.setVisibility(GONE);
binding.depictionsEditButton.setVisibility(GONE);
} else {
binding.categoryEditButton.setVisibility(VISIBLE);
binding.descriptionEdit.setVisibility(VISIBLE);
binding.depictionsEditButton.setVisibility(VISIBLE);
}
if(applicationKvStore.getBoolean("login_skipped")){
@ -313,7 +318,7 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment implements
}
public void launchZoomActivity(final View view) {
final boolean hasPermission = PermissionUtils.hasPermission(getActivity(), PermissionUtils.PERMISSIONS_STORAGE);
final boolean hasPermission = PermissionUtils.hasPermission(getActivity(), PermissionUtils.getPERMISSIONS_STORAGE());
if (hasPermission) {
launchZoomActivityAfterPermissionCheck(view);
} else {
@ -323,7 +328,7 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment implements
},
R.string.storage_permission_title,
R.string.read_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE
PermissionUtils.getPERMISSIONS_STORAGE()
);
}
}
@ -400,7 +405,6 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment implements
}
);
binding.progressBarEdit.setVisibility(GONE);
binding.descriptionEdit.setVisibility(VISIBLE);
}
@Override
@ -678,7 +682,9 @@ public class MediaDetailFragment extends CommonsDaggerSupportFragment implements
// Stick in a filler element.
allCategories.add(getString(R.string.detail_panel_cats_none));
}
if(sessionManager.isUserLoggedIn()) {
binding.categoryEditButton.setVisibility(VISIBLE);
}
rebuildCatList(allCategories);
}

View file

@ -285,7 +285,7 @@ public class OkHttpJsonApiClient {
throws Exception {
Timber.d("Fetching nearby items at radius %s", radius);
Timber.d("CUSTOM_SPARQL%s", String.valueOf(customQuery != null));
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
final String wikidataQuery;
if (customQuery != null) {
wikidataQuery = customQuery;
@ -344,7 +344,7 @@ public class OkHttpJsonApiClient {
final boolean shouldQueryForMonuments, final String customQuery)
throws Exception {
Timber.d("CUSTOM_SPARQL%s", String.valueOf(customQuery != null));
Timber.d("CUSTOM_SPARQL: %s", String.valueOf(customQuery != null));
final String wikidataQuery;
if (customQuery != null) {

View file

@ -232,7 +232,7 @@ public class MoreBottomSheetFragment extends BottomSheetDialogFragment {
}
protected void onPeerReviewClicked() {
ReviewActivity.startYourself(getActivity(), getString(R.string.title_activity_review));
ReviewActivity.Companion.startYourself(getActivity(), getString(R.string.title_activity_review));
}
}

View file

@ -43,7 +43,6 @@ import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
@ -701,7 +700,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
= new LatLng(Double.parseDouble(locationLatLng[0]),
Double.parseDouble(locationLatLng[1]), 1f);
} else {
lastKnownLocation = MapUtils.defaultLatLng;
lastKnownLocation = MapUtils.getDefaultLatLng();
}
if (binding.map != null) {
moveCameraToPosition(
@ -793,7 +792,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
hideBottomSheet();
binding.nearbyFilter.searchViewLayout.searchView.setOnQueryTextFocusChangeListener(
(v, hasFocus) -> {
LayoutUtils.setLayoutHeightAllignedToWidth(1.25,
LayoutUtils.setLayoutHeightAlignedToWidth(1.25,
binding.nearbyFilterList.getRoot());
if (hasFocus) {
binding.nearbyFilterList.getRoot().setVisibility(View.VISIBLE);
@ -834,7 +833,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
.getLayoutParams().width = (int) LayoutUtils.getScreenWidth(getActivity(),
0.75);
binding.nearbyFilterList.searchListView.setAdapter(nearbyFilterSearchRecyclerViewAdapter);
LayoutUtils.setLayoutHeightAllignedToWidth(1.25, binding.nearbyFilterList.getRoot());
LayoutUtils.setLayoutHeightAlignedToWidth(1.25, binding.nearbyFilterList.getRoot());
compositeDisposable.add(
RxSearchView.queryTextChanges(binding.nearbyFilter.searchViewLayout.searchView)
.takeUntil(RxView.detaches(binding.nearbyFilter.searchViewLayout.searchView))

View file

@ -11,13 +11,10 @@ import static fr.free.nrw.commons.nearby.CheckBoxTriStates.UNKNOWN;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import android.location.Location;
import android.view.View;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType;
@ -26,14 +23,10 @@ import fr.free.nrw.commons.nearby.CheckBoxTriStates;
import fr.free.nrw.commons.nearby.Label;
import fr.free.nrw.commons.nearby.MarkerPlaceGroup;
import fr.free.nrw.commons.nearby.NearbyController;
import fr.free.nrw.commons.nearby.NearbyFilterState;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.PlaceDao;
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.LocationUtils;
import fr.free.nrw.commons.wikidata.WikidataEditListener;
import io.reactivex.disposables.CompositeDisposable;
import java.lang.reflect.Proxy;
import java.util.List;
import timber.log.Timber;

View file

@ -1,288 +0,0 @@
package fr.free.nrw.commons.notification;
import android.annotation.SuppressLint;
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 androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.android.material.snackbar.Snackbar;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.databinding.ActivityNotificationBinding;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.notification.models.Notification;
import fr.free.nrw.commons.notification.models.NotificationType;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import javax.inject.Inject;
import kotlin.Unit;
import timber.log.Timber;
/**
* Created by root on 18.12.2017.
*/
public class NotificationActivity extends BaseActivity {
private ActivityNotificationBinding binding;
@Inject
NotificationController controller;
@Inject
SessionManager sessionManager;
private static final String TAG_NOTIFICATION_WORKER_FRAGMENT = "NotificationWorkerFragment";
private NotificationWorkerFragment mNotificationWorkerFragment;
private NotificatinAdapter adapter;
private List<Notification> notificationList;
MenuItem notificationMenuItem;
/**
* Boolean isRead is true if this notification activity is for read section of notification.
*/
private boolean isRead;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
isRead = getIntent().getStringExtra("title").equals("read");
binding = ActivityNotificationBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
mNotificationWorkerFragment = (NotificationWorkerFragment) getFragmentManager()
.findFragmentByTag(TAG_NOTIFICATION_WORKER_FRAGMENT);
initListView();
setPageTitle();
setSupportActionBar(binding.toolbar.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
/**
* If this is unread section of the notifications, removeNotification method
* Marks the notification as read,
* Removes the notification from unread,
* Displays the Snackbar.
*
* Otherwise returns (read section).
*
* @param notification
*/
@SuppressLint("CheckResult")
public void removeNotification(Notification notification) {
if (isRead) {
return;
}
Disposable disposable = Observable.defer((Callable<ObservableSource<Boolean>>)
() -> controller.markAsRead(notification))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
if (result) {
notificationList.remove(notification);
setItems(notificationList);
adapter.notifyDataSetChanged();
ViewUtil.showLongSnackbar(binding.container,getString(R.string.notification_mark_read));
if (notificationList.size() == 0) {
setEmptyView();
binding.container.setVisibility(View.GONE);
binding.noNotificationBackground.setVisibility(View.VISIBLE);
}
} else {
adapter.notifyDataSetChanged();
setItems(notificationList);
ViewUtil.showLongToast(this,getString(R.string.some_error));
}
}, throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
this, logoutListener);
} else {
Timber.e(throwable, "Error occurred while loading notifications");
throwable.printStackTrace();
ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications);
binding.progressBar.setVisibility(View.GONE);
ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications);
}
binding.progressBar.setVisibility(View.GONE);
});
compositeDisposable.add(disposable);
}
private void initListView() {
binding.listView.setLayoutManager(new LinearLayoutManager(this));
DividerItemDecoration itemDecor = new DividerItemDecoration(binding.listView.getContext(), DividerItemDecoration.VERTICAL);
binding.listView.addItemDecoration(itemDecor);
if (isRead) {
refresh(true);
} else {
refresh(false);
}
adapter = new NotificatinAdapter(item -> {
Timber.d("Notification clicked %s", item.getLink());
if (item.getNotificationType() == NotificationType.EMAIL){
ViewUtil.showLongSnackbar(binding.container,getString(R.string.check_your_email_inbox));
} else {
handleUrl(item.getLink());
}
removeNotification(item);
return Unit.INSTANCE;
});
binding.listView.setAdapter(adapter);
}
private void refresh(boolean archived) {
if (!NetworkUtils.isInternetConnectionEstablished(this)) {
binding.progressBar.setVisibility(View.GONE);
Snackbar.make(binding.container, R.string.no_internet, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.retry, view -> refresh(archived)).show();
} else {
addNotifications(archived);
}
binding.progressBar.setVisibility(View.VISIBLE);
binding.noNotificationBackground.setVisibility(View.GONE);
binding.container.setVisibility(View.VISIBLE);
}
@SuppressLint("CheckResult")
private void addNotifications(boolean archived) {
Timber.d("Add notifications");
if (mNotificationWorkerFragment == null) {
binding.progressBar.setVisibility(View.VISIBLE);
compositeDisposable.add(controller.getNotifications(archived)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(notificationList -> {
Collections.reverse(notificationList);
Timber.d("Number of notifications is %d", notificationList.size());
this.notificationList = notificationList;
if (notificationList.size()==0){
setEmptyView();
binding.container.setVisibility(View.GONE);
binding.noNotificationBackground.setVisibility(View.VISIBLE);
} else {
setItems(notificationList);
}
binding.progressBar.setVisibility(View.GONE);
}, throwable -> {
Timber.e(throwable, "Error occurred while loading notifications ");
ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications);
binding.progressBar.setVisibility(View.GONE);
}));
} else {
notificationList = mNotificationWorkerFragment.getNotificationList();
setItems(notificationList);
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.menu_notifications, menu);
notificationMenuItem = menu.findItem(R.id.archived);
setMenuItemTitle();
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle item selection
switch (item.getItemId()) {
case R.id.archived:
if (item.getTitle().equals(getString(R.string.menu_option_read))) {
NotificationActivity.startYourself(NotificationActivity.this, "read");
}else if (item.getTitle().equals(getString(R.string.menu_option_unread))) {
onBackPressed();
}
return true;
default:
return super.onOptionsItemSelected(item);
}
}
private void handleUrl(String url) {
if (url == null || url.equals("")) {
return;
}
Utils.handleWebUrl(this, Uri.parse(url));
}
private void setItems(List<Notification> notificationList) {
if (notificationList == null || notificationList.isEmpty()) {
ViewUtil.showShortSnackbar(binding.container, R.string.no_notifications);
/*progressBar.setVisibility(View.GONE);
recyclerView.setVisibility(View.GONE);*/
binding.container.setVisibility(View.GONE);
setEmptyView();
binding.noNotificationBackground.setVisibility(View.VISIBLE);
return;
}
binding.container.setVisibility(View.VISIBLE);
binding.noNotificationBackground.setVisibility(View.GONE);
adapter.setItems(notificationList);
}
public static void startYourself(Context context, String title) {
Intent intent = new Intent(context, NotificationActivity.class);
intent.putExtra("title", title);
context.startActivity(intent);
}
private void setPageTitle() {
if (getSupportActionBar() != null) {
if (isRead) {
getSupportActionBar().setTitle(R.string.read_notifications);
} else {
getSupportActionBar().setTitle(R.string.notifications);
}
}
}
private void setEmptyView() {
if (isRead) {
binding.noNotificationText.setText(R.string.no_read_notification);
}else {
binding.noNotificationText.setText(R.string.no_notification);
}
}
private void setMenuItemTitle() {
if (isRead) {
notificationMenuItem.setTitle(R.string.menu_option_unread);
}else {
notificationMenuItem.setTitle(R.string.menu_option_read);
}
}
}

View file

@ -0,0 +1,247 @@
package fr.free.nrw.commons.notification
import android.annotation.SuppressLint
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.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.databinding.ActivityNotificationBinding
import fr.free.nrw.commons.notification.models.Notification
import fr.free.nrw.commons.notification.models.NotificationType
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.NetworkUtils
import fr.free.nrw.commons.utils.ViewUtil
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import javax.inject.Inject
/**
* Created by root on 18.12.2017.
*/
class NotificationActivity : BaseActivity() {
private lateinit var binding: ActivityNotificationBinding
@Inject
lateinit var controller: NotificationController
@Inject
lateinit var sessionManager: SessionManager
private val tagNotificationWorkerFragment = "NotificationWorkerFragment"
private var mNotificationWorkerFragment: NotificationWorkerFragment? = null
private lateinit var adapter: NotificationAdapter
private var notificationList: MutableList<Notification> = mutableListOf()
private var notificationMenuItem: MenuItem? = null
/**
* Boolean isRead is true if this notification activity is for read section of notification.
*/
private var isRead: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isRead = intent.getStringExtra("title") == "read"
binding = ActivityNotificationBinding.inflate(layoutInflater)
setContentView(binding.root)
mNotificationWorkerFragment = supportFragmentManager.findFragmentByTag(
tagNotificationWorkerFragment
) as? NotificationWorkerFragment
initListView()
setPageTitle()
setSupportActionBar(binding.toolbar.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
@SuppressLint("CheckResult", "NotifyDataSetChanged")
fun removeNotification(notification: Notification) {
if (isRead) return
val disposable = Observable.defer { controller.markAsRead(notification) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
if (result) {
notificationList.remove(notification)
setItems(notificationList)
adapter.notifyDataSetChanged()
ViewUtil.showLongSnackbar(binding.container, getString(R.string.notification_mark_read))
if (notificationList.isEmpty()) {
setEmptyView()
binding.container.visibility = View.GONE
binding.noNotificationBackground.visibility = View.VISIBLE
}
} else {
adapter.notifyDataSetChanged()
setItems(notificationList)
ViewUtil.showLongToast(this, getString(R.string.some_error))
}
}, { throwable ->
if (throwable is InvalidLoginTokenException) {
val username = sessionManager.getUserName()
val logoutListener = CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
)
CommonsApplication.instance.clearApplicationData(this, logoutListener)
} else {
Timber.e(throwable, "Error occurred while loading notifications")
ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications)
}
binding.progressBar.visibility = View.GONE
})
compositeDisposable.add(disposable)
}
private fun initListView() {
binding.listView.layoutManager = LinearLayoutManager(this)
val itemDecor = DividerItemDecoration(binding.listView.context, DividerItemDecoration.VERTICAL)
binding.listView.addItemDecoration(itemDecor)
refresh(isRead)
adapter = NotificationAdapter { item ->
Timber.d("Notification clicked %s", item.link)
if (item.notificationType == NotificationType.EMAIL) {
ViewUtil.showLongSnackbar(binding.container, getString(R.string.check_your_email_inbox))
} else {
handleUrl(item.link)
}
removeNotification(item)
}
binding.listView.adapter = adapter
}
private fun refresh(archived: Boolean) {
if (!NetworkUtils.isInternetConnectionEstablished(this)) {
binding.progressBar.visibility = View.GONE
Snackbar.make(binding.container, R.string.no_internet, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.retry) { refresh(archived) }
.show()
} else {
addNotifications(archived)
}
binding.progressBar.visibility = View.VISIBLE
binding.noNotificationBackground.visibility = View.GONE
binding.container.visibility = View.VISIBLE
}
@SuppressLint("CheckResult")
private fun addNotifications(archived: Boolean) {
Timber.d("Add notifications")
if (mNotificationWorkerFragment == null) {
binding.progressBar.visibility = View.VISIBLE
compositeDisposable.add(controller.getNotifications(archived)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ notificationList ->
notificationList.reversed()
Timber.d("Number of notifications is %d", notificationList.size)
this.notificationList = notificationList.toMutableList()
if (notificationList.isEmpty()) {
setEmptyView()
binding.container.visibility = View.GONE
binding.noNotificationBackground.visibility = View.VISIBLE
} else {
setItems(notificationList)
}
binding.progressBar.visibility = View.GONE
}, { throwable ->
Timber.e(throwable, "Error occurred while loading notifications")
ViewUtil.showShortSnackbar(binding.container, R.string.error_notifications)
binding.progressBar.visibility = View.GONE
}))
} else {
notificationList = mNotificationWorkerFragment?.notificationList?.toMutableList() ?: mutableListOf()
setItems(notificationList)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_notifications, menu)
notificationMenuItem = menu.findItem(R.id.archived)
setMenuItemTitle()
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.archived -> {
if (item.title == getString(R.string.menu_option_read)) {
startYourself(this, "read")
} else if (item.title == getString(R.string.menu_option_unread)) {
onBackPressed()
}
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun handleUrl(url: String?) {
if (url.isNullOrEmpty()) return
Utils.handleWebUrl(this, Uri.parse(url))
}
private fun setItems(notificationList: List<Notification>?) {
if (notificationList.isNullOrEmpty()) {
ViewUtil.showShortSnackbar(binding.container, R.string.no_notifications)
binding.container.visibility = View.GONE
setEmptyView()
binding.noNotificationBackground.visibility = View.VISIBLE
return
}
binding.container.visibility = View.VISIBLE
binding.noNotificationBackground.visibility = View.GONE
adapter.items = notificationList
}
private fun setPageTitle() {
supportActionBar?.title = if (isRead) {
getString(R.string.read_notifications)
} else {
getString(R.string.notifications)
}
}
private fun setEmptyView() {
binding.noNotificationText.text = if (isRead) {
getString(R.string.no_read_notification)
} else {
getString(R.string.no_notification)
}
}
private fun setMenuItemTitle() {
notificationMenuItem?.title = if (isRead) {
getString(R.string.menu_option_unread)
} else {
getString(R.string.menu_option_read)
}
}
companion object {
fun startYourself(context: Context, title: String) {
val intent = Intent(context, NotificationActivity::class.java)
intent.putExtra("title", title)
context.startActivity(intent)
}
}
}

View file

@ -3,7 +3,7 @@ package fr.free.nrw.commons.notification
import fr.free.nrw.commons.notification.models.Notification
import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
internal class NotificatinAdapter(
internal class NotificationAdapter(
onNotificationClicked: (Notification) -> Unit,
) : BaseDelegateAdapter<Notification>(
notificationDelegate(onNotificationClicked),

View file

@ -1,33 +0,0 @@
package fr.free.nrw.commons.notification;
import fr.free.nrw.commons.notification.models.Notification;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Observable;
import io.reactivex.Single;
/**
* Created by root on 19.12.2017.
*/
@Singleton
public class NotificationController {
private NotificationClient notificationClient;
@Inject
public NotificationController(NotificationClient notificationClient) {
this.notificationClient = notificationClient;
}
public Single<List<Notification>> getNotifications(boolean archived) {
return notificationClient.getNotifications(archived);
}
Observable<Boolean> markAsRead(Notification notification) {
return notificationClient.markNotificationAsRead(notification.getNotificationId());
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.notification
import fr.free.nrw.commons.notification.models.Notification
import javax.inject.Inject
import javax.inject.Singleton
import io.reactivex.Observable
import io.reactivex.Single
/**
* Created by root on 19.12.2017.
*/
@Singleton
class NotificationController @Inject constructor(
private val notificationClient: NotificationClient
) {
fun getNotifications(archived: Boolean): Single<List<Notification>> {
return notificationClient.getNotifications(archived)
}
fun markAsRead(notification: Notification): Observable<Boolean> {
return notificationClient.markNotificationAsRead(notification.notificationId)
}
}

View file

@ -1,73 +0,0 @@
package fr.free.nrw.commons.notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import static androidx.core.app.NotificationCompat.DEFAULT_ALL;
import static androidx.core.app.NotificationCompat.PRIORITY_HIGH;
/**
* Helper class that can be used to build a generic notification
* Going forward all notifications should be built using this helper class
*/
@Singleton
public class NotificationHelper {
public static final int NOTIFICATION_DELETE = 1;
public static final int NOTIFICATION_EDIT_CATEGORY = 2;
public static final int NOTIFICATION_EDIT_COORDINATES = 3;
public static final int NOTIFICATION_EDIT_DESCRIPTION = 4;
public static final int NOTIFICATION_EDIT_DEPICTIONS = 5;
private final NotificationManager notificationManager;
private final NotificationCompat.Builder notificationBuilder;
@Inject
public NotificationHelper(final Context context) {
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationBuilder = new NotificationCompat
.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)
.setOnlyAlertOnce(true);
}
/**
* Public interface to build and show a notification in the notification bar
* @param context passed context
* @param notificationTitle title of the notification
* @param notificationMessage message to be displayed in the notification
* @param notificationId the notificationID
* @param intent the intent to be fired when the notification is clicked
*/
public void showNotification(
final Context context,
final String notificationTitle,
final String notificationMessage,
final int notificationId,
final Intent intent
) {
notificationBuilder.setDefaults(DEFAULT_ALL)
.setContentTitle(notificationTitle)
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(notificationMessage))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(0, 0, false)
.setOngoing(false)
.setPriority(PRIORITY_HIGH);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE; // This flag was introduced in API 23
}
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, flags);
notificationBuilder.setContentIntent(pendingIntent);
notificationManager.notify(notificationId, notificationBuilder.build());
}
}

View file

@ -0,0 +1,73 @@
package fr.free.nrw.commons.notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import javax.inject.Inject
import javax.inject.Singleton
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import androidx.core.app.NotificationCompat.DEFAULT_ALL
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
/**
* Helper class that can be used to build a generic notification
* Going forward all notifications should be built using this helper class
*/
@Singleton
class NotificationHelper @Inject constructor(
context: Context
) {
companion object {
const val NOTIFICATION_DELETE = 1
const val NOTIFICATION_EDIT_CATEGORY = 2
const val NOTIFICATION_EDIT_COORDINATES = 3
const val NOTIFICATION_EDIT_DESCRIPTION = 4
const val NOTIFICATION_EDIT_DEPICTIONS = 5
}
private val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notificationBuilder: NotificationCompat.Builder = NotificationCompat
.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)
.setOnlyAlertOnce(true)
/**
* Public interface to build and show a notification in the notification bar
* @param context passed context
* @param notificationTitle title of the notification
* @param notificationMessage message to be displayed in the notification
* @param notificationId the notificationID
* @param intent the intent to be fired when the notification is clicked
*/
fun showNotification(
context: Context,
notificationTitle: String,
notificationMessage: String,
notificationId: Int,
intent: Intent
) {
notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentTitle(notificationTitle)
.setStyle(NotificationCompat.BigTextStyle().bigText(notificationMessage))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(0, 0, false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_HIGH)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(context, 1, intent, flags)
notificationBuilder.setContentIntent(pendingIntent)
notificationManager.notify(notificationId, notificationBuilder.build())
}
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.notification;
import android.app.Fragment;
import android.os.Bundle;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.notification.models.Notification;
import java.util.List;
/**
* Created by knightshade on 25/2/18.
*/
public class NotificationWorkerFragment extends Fragment {
private List<Notification> notificationList;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
public void setNotificationList(List<Notification> notificationList){
this.notificationList = notificationList;
}
public List<Notification> getNotificationList(){
return notificationList;
}
}

View file

@ -0,0 +1,20 @@
package fr.free.nrw.commons.notification
import android.app.Fragment
import android.os.Bundle
import fr.free.nrw.commons.notification.models.Notification
/**
* Created by knightshade on 25/2/18.
*/
class NotificationWorkerFragment : Fragment() {
var notificationList: List<Notification>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
}

View file

@ -1,28 +0,0 @@
package fr.free.nrw.commons.notification.models;
public enum NotificationType {
THANK_YOU_EDIT("thank-you-edit"),
EDIT_USER_TALK("edit-user-talk"),
MENTION("mention"),
EMAIL("email"),
WELCOME("welcome"),
UNKNOWN("unknown");
private String type;
NotificationType(String type) {
this.type = type;
}
public String getType() {
return type;
}
public static NotificationType handledValueOf(String name) {
for (NotificationType e : values()) {
if (e.getType().equals(name)) {
return e;
}
}
return UNKNOWN;
}
}

View file

@ -0,0 +1,33 @@
package fr.free.nrw.commons.notification.models
enum class NotificationType(private val type: String) {
THANK_YOU_EDIT("thank-you-edit"),
EDIT_USER_TALK("edit-user-talk"),
MENTION("mention"),
EMAIL("email"),
WELCOME("welcome"),
UNKNOWN("unknown");
// Getter for the type property
fun getType(): String {
return type
}
companion object {
// Returns the corresponding NotificationType for a given name or UNKNOWN
// if no match is found
fun handledValueOf(name: String): NotificationType {
for (e in values()) {
if (e.type == name) {
return e
}
}
return UNKNOWN
}
}
}

View file

@ -157,7 +157,7 @@ public class ProfileActivity extends BaseActivity {
@Override
public void onDestroy() {
super.onDestroy();
compositeDisposable.clear();
getCompositeDisposable().clear();
}
/**

View file

@ -1,104 +1,45 @@
package fr.free.nrw.commons.profile.achievements
/**
* Represents Achievements class and stores all the parameters
* Represents Achievements data class and stores all the parameters.
* Immutable version with default values for optional properties.
*/
class Achievements {
data class Achievements(
val uniqueUsedImages: Int = 0,
val articlesUsingImages: Int = 0,
val thanksReceived: Int = 0,
val featuredImages: Int = 0,
val qualityImages: Int = 0,
val imagesUploaded: Int = 0,
val revertCount: Int = 0
) {
/**
* The count of unique images used by the wiki.
* @return The count of unique images used.
* @param uniqueUsedImages The count to set for unique images used.
*/
var uniqueUsedImages = 0
private var articlesUsingImages = 0
/**
* The count of thanks received.
* @return The count of thanks received.
* @param thanksReceived The count to set for thanks received.
*/
var thanksReceived = 0
/**
* The count of featured images.
* @return The count of featured images.
* @param featuredImages The count to set for featured images.
*/
var featuredImages = 0
/**
* The count of quality images.
* @return The count of quality images.
* @param qualityImages The count to set for quality images.
*/
var qualityImages = 0
/**
* The count of images uploaded.
* @return The count of images uploaded.
* @param imagesUploaded The count to set for images uploaded.
*/
var imagesUploaded = 0
private var revertCount = 0
constructor() {}
/**
* constructor for achievements class to set its data members
* @param uniqueUsedImages
* @param articlesUsingImages
* @param thanksReceived
* @param featuredImages
* @param imagesUploaded
* @param revertCount
*/
constructor(
uniqueUsedImages: Int,
articlesUsingImages: Int,
thanksReceived: Int,
featuredImages: Int,
qualityImages: Int,
imagesUploaded: Int,
revertCount: Int,
) {
this.uniqueUsedImages = uniqueUsedImages
this.articlesUsingImages = articlesUsingImages
this.thanksReceived = thanksReceived
this.featuredImages = featuredImages
this.qualityImages = qualityImages
this.imagesUploaded = imagesUploaded
this.revertCount = revertCount
}
/**
* used to calculate the percentages of images that haven't been reverted
* @return
* Used to calculate the percentages of images that haven't been reverted.
* Returns 100 if imagesUploaded is 0 to avoid division by zero.
*/
val notRevertPercentage: Int
get() =
try {
get() = if (imagesUploaded > 0) {
(imagesUploaded - revertCount) * 100 / imagesUploaded
} catch (divideByZero: ArithmeticException) {
} else {
100
}
companion object {
/**
* Get Achievements object from FeedbackResponse
* Get Achievements object from FeedbackResponse.
*
* @param response
* @return
* @param response The feedback response to convert.
* @return An Achievements object with values from the response.
*/
@JvmStatic
fun from(response: FeedbackResponse): Achievements =
Achievements(
response.uniqueUsedImages,
response.articlesUsingImages,
response.thanksReceived,
response.featuredImages.featuredPicturesOnWikimediaCommons,
response.featuredImages.qualityImages,
0,
response.deletedUploads,
fun from(response: FeedbackResponse): Achievements = Achievements(
uniqueUsedImages = response.uniqueUsedImages,
articlesUsingImages = response.articlesUsingImages,
thanksReceived = response.thanksReceived,
featuredImages = response.featuredImages.featuredPicturesOnWikimediaCommons,
qualityImages = response.featuredImages.qualityImages,
imagesUploaded = 0, // Assuming imagesUploaded should be 0
revertCount = response.deletedUploads
)
}
}

View file

@ -295,8 +295,18 @@ public class AchievementsFragment extends CommonsDaggerSupportFragment {
* @param uploadCount
*/
private void setAchievementsUploadCount(Achievements achievements, int uploadCount) {
achievements.setImagesUploaded(uploadCount);
hideProgressBar(achievements);
// Create a new instance of Achievements with updated imagesUploaded
Achievements updatedAchievements = new Achievements(
achievements.getUniqueUsedImages(),
achievements.getArticlesUsingImages(),
achievements.getThanksReceived(),
achievements.getFeaturedImages(),
achievements.getQualityImages(),
uploadCount, // Update imagesUploaded with new value
achievements.getRevertCount()
);
hideProgressBar(updatedAchievements);
}
/**

View file

@ -6,7 +6,7 @@ import com.google.gson.annotations.SerializedName
* Represents Featured Images on WikiMedia Commons platform
* Used by Achievements and FeedbackResponse (objects) of the user
*/
class FeaturedImages(
data class FeaturedImages(
@field:SerializedName("Quality_images") val qualityImages: Int,
@field:SerializedName("Featured_pictures_on_Wikimedia_Commons") val featuredPicturesOnWikimediaCommons: Int,
)

View file

@ -1,146 +0,0 @@
package fr.free.nrw.commons.quiz;
import android.content.Intent;
import android.os.Bundle;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import com.facebook.drawee.drawable.ProgressBarDrawable;
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
import fr.free.nrw.commons.databinding.ActivityQuizBinding;
import java.util.ArrayList;
import fr.free.nrw.commons.R;
public class QuizActivity extends AppCompatActivity {
private ActivityQuizBinding binding;
private final QuizController quizController = new QuizController();
private ArrayList<QuizQuestion> quiz = new ArrayList<>();
private int questionIndex = 0;
private int score;
/**
* isPositiveAnswerChecked : represents yes click event
*/
private boolean isPositiveAnswerChecked;
/**
* isNegativeAnswerChecked : represents no click event
*/
private boolean isNegativeAnswerChecked;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityQuizBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
quizController.initialize(this);
setSupportActionBar(binding.toolbar.toolbar);
binding.nextButton.setOnClickListener(view -> notKnowAnswer());
displayQuestion();
}
/**
* to move to next question and check whether answer is selected or not
*/
public void setNextQuestion(){
if ( questionIndex <= quiz.size() && (isPositiveAnswerChecked || isNegativeAnswerChecked)) {
evaluateScore();
}
}
public void notKnowAnswer(){
customAlert("Information", quiz.get(questionIndex).getAnswerMessage());
}
/**
* to give warning before ending quiz
*/
@Override
public void onBackPressed() {
new AlertDialog.Builder(this)
.setTitle(getResources().getString(R.string.warning))
.setMessage(getResources().getString(R.string.quiz_back_button))
.setPositiveButton(R.string.continue_message, (dialog, which) -> {
final Intent intent = new Intent(this, QuizResultActivity.class);
dialog.dismiss();
intent.putExtra("QuizResult", score);
startActivity(intent);
})
.setNegativeButton("Cancel", (dialogInterface, i) -> dialogInterface.dismiss())
.create()
.show();
}
/**
* to display the question
*/
public void displayQuestion() {
quiz = quizController.getQuiz();
binding.question.questionText.setText(quiz.get(questionIndex).getQuestion());
binding.questionTitle.setText(
getResources().getString(R.string.question) +
quiz.get(questionIndex).getQuestionNumber()
);
binding.question.questionImage.setHierarchy(GenericDraweeHierarchyBuilder
.newInstance(getResources())
.setFailureImage(VectorDrawableCompat.create(getResources(),
R.drawable.ic_error_outline_black_24dp, getTheme()))
.setProgressBarImage(new ProgressBarDrawable())
.build());
binding.question.questionImage.setImageURI(quiz.get(questionIndex).getUrl());
isPositiveAnswerChecked = false;
isNegativeAnswerChecked = false;
binding.answer.quizPositiveAnswer.setOnClickListener(view -> {
isPositiveAnswerChecked = true;
setNextQuestion();
});
binding.answer.quizNegativeAnswer.setOnClickListener(view -> {
isNegativeAnswerChecked = true;
setNextQuestion();
});
}
/**
* to evaluate score and check whether answer is correct or wrong
*/
public void evaluateScore() {
if ((quiz.get(questionIndex).isAnswer() && isPositiveAnswerChecked) ||
(!quiz.get(questionIndex).isAnswer() && isNegativeAnswerChecked) ){
customAlert(getResources().getString(R.string.correct),
quiz.get(questionIndex).getAnswerMessage());
score++;
} else {
customAlert(getResources().getString(R.string.wrong),
quiz.get(questionIndex).getAnswerMessage());
}
}
/**
* to display explanation after each answer, update questionIndex and move to next question
* @param title the alert title
* @param Message the alert message
*/
public void customAlert(final String title, final String Message) {
new AlertDialog.Builder(this)
.setTitle(title)
.setMessage(Message)
.setPositiveButton(R.string.continue_message, (dialog, which) -> {
questionIndex++;
if (questionIndex == quiz.size()) {
final Intent intent = new Intent(this, QuizResultActivity.class);
dialog.dismiss();
intent.putExtra("QuizResult", score);
startActivity(intent);
} else {
displayQuestion();
}
})
.create()
.show();
}
}

View file

@ -0,0 +1,154 @@
package fr.free.nrw.commons.quiz
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.facebook.drawee.drawable.ProgressBarDrawable
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder
import fr.free.nrw.commons.databinding.ActivityQuizBinding
import java.util.ArrayList
import fr.free.nrw.commons.R
class QuizActivity : AppCompatActivity() {
private lateinit var binding: ActivityQuizBinding
private val quizController = QuizController()
private var quiz = ArrayList<QuizQuestion>()
private var questionIndex = 0
private var score = 0
/**
* isPositiveAnswerChecked : represents yes click event
*/
private var isPositiveAnswerChecked = false
/**
* isNegativeAnswerChecked : represents no click event
*/
private var isNegativeAnswerChecked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityQuizBinding.inflate(layoutInflater)
setContentView(binding.root)
quizController.initialize(this)
setSupportActionBar(binding.toolbar.toolbar)
binding.nextButton.setOnClickListener { notKnowAnswer() }
displayQuestion()
}
/**
* To move to next question and check whether answer is selected or not
*/
fun setNextQuestion() {
if (questionIndex <= quiz.size && (isPositiveAnswerChecked || isNegativeAnswerChecked)) {
evaluateScore()
}
}
private fun notKnowAnswer() {
customAlert("Information", quiz[questionIndex].answerMessage)
}
/**
* To give warning before ending quiz
*/
override fun onBackPressed() {
AlertDialog.Builder(this)
.setTitle(getString(R.string.warning))
.setMessage(getString(R.string.quiz_back_button))
.setPositiveButton(R.string.continue_message) { dialog, _ ->
val intent = Intent(this, QuizResultActivity::class.java)
dialog.dismiss()
intent.putExtra("QuizResult", score)
startActivity(intent)
}
.setNegativeButton("Cancel") { dialogInterface, _ -> dialogInterface.dismiss() }
.create()
.show()
}
/**
* To display the question
*/
@SuppressLint("SetTextI18n")
private fun displayQuestion() {
quiz = quizController.getQuiz()
binding.question.questionText.text = quiz[questionIndex].question
binding.questionTitle.text = getString(R.string.question) + quiz[questionIndex].questionNumber
binding.question.questionImage.hierarchy = GenericDraweeHierarchyBuilder
.newInstance(resources)
.setFailureImage(VectorDrawableCompat.create(resources, R.drawable.ic_error_outline_black_24dp, theme))
.setProgressBarImage(ProgressBarDrawable())
.build()
binding.question.questionImage.setImageURI(quiz[questionIndex].getUrl())
isPositiveAnswerChecked = false
isNegativeAnswerChecked = false
binding.answer.quizPositiveAnswer.setOnClickListener {
isPositiveAnswerChecked = true
setNextQuestion()
}
binding.answer.quizNegativeAnswer.setOnClickListener {
isNegativeAnswerChecked = true
setNextQuestion()
}
}
/**
* To evaluate score and check whether answer is correct or wrong
*/
fun evaluateScore() {
if (
(quiz[questionIndex].isAnswer && isPositiveAnswerChecked)
||
(!quiz[questionIndex].isAnswer && isNegativeAnswerChecked)
) {
customAlert(
getString(R.string.correct),
quiz[questionIndex].answerMessage
)
score++
} else {
customAlert(
getString(R.string.wrong),
quiz[questionIndex].answerMessage
)
}
}
/**
* To display explanation after each answer, update questionIndex and move to next question
* @param title The alert title
* @param message The alert message
*/
fun customAlert(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.continue_message) { dialog, _ ->
questionIndex++
if (questionIndex == quiz.size) {
val intent = Intent(this, QuizResultActivity::class.java)
dialog.dismiss()
intent.putExtra("QuizResult", score)
startActivity(intent)
} else {
displayQuestion()
}
}
.create()
.show()
}
}

View file

@ -1,167 +0,0 @@
package fr.free.nrw.commons.quiz;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.utils.DialogUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
/**
* fetches the number of images uploaded and number of images reverted.
* Then it calculates the percentage of the images reverted
* if the percentage of images reverted after last quiz exceeds 50% and number of images uploaded is
* greater than 50, then quiz is popped up
*/
@Singleton
public class QuizChecker {
private int revertCount ;
private int totalUploadCount ;
private boolean isRevertCountFetched;
private boolean isUploadCountFetched;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
private final SessionManager sessionManager;
private final OkHttpJsonApiClient okHttpJsonApiClient;
private final JsonKvStore revertKvStore;
private static final int UPLOAD_COUNT_THRESHOLD = 5;
private static final String REVERT_PERCENTAGE_FOR_MESSAGE = "50%";
private final String REVERT_SHARED_PREFERENCE = "revertCount";
private final String UPLOAD_SHARED_PREFERENCE = "uploadCount";
/**
* constructor to set the parameters for quiz
* @param sessionManager
* @param okHttpJsonApiClient
*/
@Inject
public QuizChecker(SessionManager sessionManager,
OkHttpJsonApiClient okHttpJsonApiClient,
@Named("default_preferences") JsonKvStore revertKvStore) {
this.sessionManager = sessionManager;
this.okHttpJsonApiClient = okHttpJsonApiClient;
this.revertKvStore = revertKvStore;
}
public void initQuizCheck(Activity activity) {
calculateRevertParameterAndShowQuiz(activity);
}
public void cleanup() {
compositeDisposable.clear();
}
/**
* to fet the total number of images uploaded
*/
private void setUploadCount() {
compositeDisposable.add(okHttpJsonApiClient
.getUploadCount(sessionManager.getUserName())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setTotalUploadCount,
t -> Timber.e(t, "Fetching upload count failed")
));
}
/**
* set the sub Title of Contibutions Activity and
* call function to check for quiz
* @param uploadCount user's upload count
*/
private void setTotalUploadCount(int uploadCount) {
totalUploadCount = uploadCount - revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0);
if ( totalUploadCount < 0){
totalUploadCount = 0;
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0);
}
isUploadCountFetched = true;
}
/**
* To call the API to get reverts count in form of JSONObject
*/
private void setRevertCount() {
compositeDisposable.add(okHttpJsonApiClient
.getAchievements(sessionManager.getUserName())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
response -> {
if (response != null) {
setRevertParameter(response.getDeletedUploads());
}
}, throwable -> Timber.e(throwable, "Fetching feedback failed"))
);
}
/**
* to calculate the number of images reverted after previous quiz
* @param revertCountFetched count of deleted uploads
*/
private void setRevertParameter(int revertCountFetched) {
revertCount = revertCountFetched - revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0);
if (revertCount < 0){
revertCount = 0;
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0);
}
isRevertCountFetched = true;
}
/**
* to check whether the criterion to call quiz is satisfied
*/
private void calculateRevertParameterAndShowQuiz(Activity activity) {
setUploadCount();
setRevertCount();
if ( revertCount < 0 || totalUploadCount < 0){
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0);
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0);
return;
}
if (isRevertCountFetched && isUploadCountFetched &&
totalUploadCount >= UPLOAD_COUNT_THRESHOLD &&
(revertCount * 100) / totalUploadCount >= 50) {
callQuiz(activity);
}
}
/**
* Alert which prompts to quiz
*/
@SuppressLint("StringFormatInvalid")
private void callQuiz(Activity activity) {
DialogUtil.showAlertDialog(activity,
activity.getString(R.string.quiz),
activity.getString(R.string.quiz_alert_message, REVERT_PERCENTAGE_FOR_MESSAGE),
activity.getString(R.string.about_translate_proceed),
activity.getString(android.R.string.cancel),
() -> startQuizActivity(activity),
null);
}
private void startQuizActivity(Activity activity) {
int newRevetSharedPrefs = revertCount + revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0);
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, newRevetSharedPrefs);
int newUploadCount = totalUploadCount + revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0);
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, newUploadCount);
Intent i = new Intent(activity, WelcomeActivity.class);
i.putExtra("isQuiz", true);
activity.startActivity(i);
}
}

View file

@ -0,0 +1,175 @@
package fr.free.nrw.commons.quiz
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import fr.free.nrw.commons.R
import fr.free.nrw.commons.WelcomeActivity
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import fr.free.nrw.commons.utils.DialogUtil
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
/**
* Fetches the number of images uploaded and number of images reverted.
* Then it calculates the percentage of the images reverted.
* If the percentage of images reverted after the last quiz exceeds 50% and number of images uploaded is
* greater than 50, then the quiz is popped up.
*/
@Singleton
class QuizChecker @Inject constructor(
private val sessionManager: SessionManager,
private val okHttpJsonApiClient: OkHttpJsonApiClient,
@Named("default_preferences") private val revertKvStore: JsonKvStore
) {
private var revertCount = 0
private var totalUploadCount = 0
private var isRevertCountFetched = false
private var isUploadCountFetched = false
private val compositeDisposable = CompositeDisposable()
private val UPLOAD_COUNT_THRESHOLD = 5
private val REVERT_PERCENTAGE_FOR_MESSAGE = "50%"
private val REVERT_SHARED_PREFERENCE = "revertCount"
private val UPLOAD_SHARED_PREFERENCE = "uploadCount"
/**
* Initializes quiz check by calculating revert parameters and showing quiz if necessary
*/
fun initQuizCheck(activity: Activity) {
calculateRevertParameterAndShowQuiz(activity)
}
/**
* Clears disposables to avoid memory leaks
*/
fun cleanup() {
compositeDisposable.clear()
}
/**
* Fetches the total number of images uploaded
*/
private fun setUploadCount() {
compositeDisposable.add(
okHttpJsonApiClient.getUploadCount(sessionManager.userName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ uploadCount -> setTotalUploadCount(uploadCount) },
{ t -> Timber.e(t, "Fetching upload count failed") }
)
)
}
/**
* Sets the total upload count after subtracting stored preference
* @param uploadCount User's upload count
*/
private fun setTotalUploadCount(uploadCount: Int) {
totalUploadCount = uploadCount - revertKvStore.getInt(
UPLOAD_SHARED_PREFERENCE,
0
)
if (totalUploadCount < 0) {
totalUploadCount = 0
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0)
}
isUploadCountFetched = true
}
/**
* Fetches the revert count using the API
*/
private fun setRevertCount() {
compositeDisposable.add(
okHttpJsonApiClient.getAchievements(sessionManager.userName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
response?.let { setRevertParameter(it.deletedUploads) }
},
{ throwable -> Timber.e(throwable, "Fetching feedback failed") }
)
)
}
/**
* Calculates the number of images reverted after the previous quiz
* @param revertCountFetched Count of deleted uploads
*/
private fun setRevertParameter(revertCountFetched: Int) {
revertCount = revertCountFetched - revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0)
if (revertCount < 0) {
revertCount = 0
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0)
}
isRevertCountFetched = true
}
/**
* Checks whether the criteria for calling the quiz are satisfied
*/
private fun calculateRevertParameterAndShowQuiz(activity: Activity) {
setUploadCount()
setRevertCount()
if (revertCount < 0 || totalUploadCount < 0) {
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0)
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0)
return
}
if (isRevertCountFetched && isUploadCountFetched &&
totalUploadCount >= UPLOAD_COUNT_THRESHOLD &&
(revertCount * 100) / totalUploadCount >= 50
) {
callQuiz(activity)
}
}
/**
* Displays an alert prompting the user to take the quiz
*/
@SuppressLint("StringFormatInvalid")
private fun callQuiz(activity: Activity) {
DialogUtil.showAlertDialog(
activity,
activity.getString(R.string.quiz),
activity.getString(R.string.quiz_alert_message, REVERT_PERCENTAGE_FOR_MESSAGE),
activity.getString(R.string.about_translate_proceed),
activity.getString(android.R.string.cancel),
{ startQuizActivity(activity) },
null
)
}
/**
* Starts the quiz activity and updates preferences for revert and upload counts
*/
private fun startQuizActivity(activity: Activity) {
val newRevertSharedPrefs = revertCount + revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0)
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, newRevertSharedPrefs)
val newUploadCount = totalUploadCount + revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0)
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, newUploadCount)
val intent = Intent(activity, WelcomeActivity::class.java).apply {
putExtra("isQuiz", true)
}
activity.startActivity(intent)
}
}

View file

@ -1,63 +0,0 @@
package fr.free.nrw.commons.quiz;
import android.content.Context;
import java.util.ArrayList;
import fr.free.nrw.commons.R;
/**
* controls the quiz in the Activity
*/
public class QuizController {
ArrayList<QuizQuestion> quiz = new ArrayList<>();
private final String URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg";
private final String URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg";
private final String URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg";
private final String URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png";
private final String URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg";
public void initialize(Context context){
QuizQuestion q1 = new QuizQuestion(1,
context.getString(R.string.quiz_question_string),
URL_FOR_SELFIE,
false,
context.getString(R.string.selfie_answer));
quiz.add(q1);
QuizQuestion q2 = new QuizQuestion(2,
context.getString(R.string.quiz_question_string),
URL_FOR_TAJ_MAHAL,
true,
context.getString(R.string.taj_mahal_answer));
quiz.add(q2);
QuizQuestion q3 = new QuizQuestion(3,
context.getString(R.string.quiz_question_string),
URL_FOR_BLURRY_IMAGE,
false,
context.getString(R.string.blurry_image_answer));
quiz.add(q3);
QuizQuestion q4 = new QuizQuestion(4,
context.getString(R.string.quiz_screenshot_question),
URL_FOR_SCREENSHOT,
false,
context.getString(R.string.screenshot_answer));
quiz.add(q4);
QuizQuestion q5 = new QuizQuestion(5,
context.getString(R.string.quiz_question_string),
URL_FOR_EVENT,
true,
context.getString(R.string.construction_event_answer));
quiz.add(q5);
}
public ArrayList<QuizQuestion> getQuiz() {
return quiz;
}
}

View file

@ -0,0 +1,76 @@
package fr.free.nrw.commons.quiz
import android.content.Context
import java.util.ArrayList
import fr.free.nrw.commons.R
/**
* Controls the quiz in the Activity
*/
class QuizController {
private val quiz: ArrayList<QuizQuestion> = ArrayList()
private val URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg"
private val URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg"
private val URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg"
private val URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png"
private val URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg"
fun initialize(context: Context) {
val q1 = QuizQuestion(
1,
context.getString(R.string.quiz_question_string),
URL_FOR_SELFIE,
false,
context.getString(R.string.selfie_answer)
)
quiz.add(q1)
val q2 = QuizQuestion(
2,
context.getString(R.string.quiz_question_string),
URL_FOR_TAJ_MAHAL,
true,
context.getString(R.string.taj_mahal_answer)
)
quiz.add(q2)
val q3 = QuizQuestion(
3,
context.getString(R.string.quiz_question_string),
URL_FOR_BLURRY_IMAGE,
false,
context.getString(R.string.blurry_image_answer)
)
quiz.add(q3)
val q4 = QuizQuestion(
4,
context.getString(R.string.quiz_screenshot_question),
URL_FOR_SCREENSHOT,
false,
context.getString(R.string.screenshot_answer)
)
quiz.add(q4)
val q5 = QuizQuestion(
5,
context.getString(R.string.quiz_question_string),
URL_FOR_EVENT,
true,
context.getString(R.string.construction_event_answer)
)
quiz.add(q5)
}
fun getQuiz(): ArrayList<QuizQuestion> {
return quiz
}
}

View file

@ -1,188 +0,0 @@
package fr.free.nrw.commons.quiz;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import fr.free.nrw.commons.databinding.ActivityQuizResultBinding;
import java.io.File;
import java.io.FileOutputStream;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.MainActivity;
/**
* Displays the final score of quiz and congratulates the user
*/
public class QuizResultActivity extends AppCompatActivity {
private ActivityQuizResultBinding binding;
private final int NUMBER_OF_QUESTIONS = 5;
private final int MULTIPLIER_TO_GET_PERCENTAGE = 20;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityQuizResultBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbar.toolbar);
binding.quizResultNext.setOnClickListener(view -> launchContributionActivity());
if ( getIntent() != null) {
Bundle extras = getIntent().getExtras();
int score = extras.getInt("QuizResult");
setScore(score);
}else{
startActivityWithFlags(
this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
super.onBackPressed();
}
}
@Override
protected void onDestroy() {
binding = null;
super.onDestroy();
}
/**
* to calculate and display percentage and score
* @param score
*/
public void setScore(int score) {
final int scorePercent = score * MULTIPLIER_TO_GET_PERCENTAGE;
binding.resultProgressBar.setProgress(scorePercent);
binding.tvResultProgress.setText(score +" / " + NUMBER_OF_QUESTIONS);
final String message = getResources().getString(R.string.congratulatory_message_quiz,scorePercent + "%");
binding.congratulatoryMessage.setText(message);
}
/**
* to go to Contibutions Activity
*/
public void launchContributionActivity(){
startActivityWithFlags(
this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
@Override
public void onBackPressed() {
startActivityWithFlags(
this, MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
super.onBackPressed();
}
/**
* Function to call intent to an activity
* @param context
* @param cls
* @param flags
* @param <T>
*/
public static <T> void startActivityWithFlags(Context context, Class<T> cls, int... flags) {
Intent intent = new Intent(context, cls);
for (int flag: flags) {
intent.addFlags(flag);
}
context.startActivity(intent);
}
/**
* to inflate menu
* @param menu
* @return
*/
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_about, menu);
return true;
}
/**
* if share option selected then take screenshot and launch alert
* @param item
* @return
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.share_app_icon) {
View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
Bitmap screenShot = getScreenShot(rootView);
showAlert(screenShot);
}
return super.onOptionsItemSelected(item);
}
/**
* to store the screenshot of image in bitmap variable temporarily
* @param view
* @return
*/
public static Bitmap getScreenShot(View view) {
View screenView = view.getRootView();
screenView.setDrawingCacheEnabled(true);
Bitmap bitmap = Bitmap.createBitmap(screenView.getDrawingCache());
screenView.setDrawingCacheEnabled(false);
return bitmap;
}
/**
* share the screenshot through social media
* @param bitmap
*/
void shareScreen(Bitmap bitmap) {
try {
File file = new File(this.getExternalCacheDir(),"screen.png");
FileOutputStream fOut = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut);
fOut.flush();
fOut.close();
file.setReadable(true, false);
final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file));
intent.setType("image/png");
startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)));
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* It display the alertDialog with Image of screenshot
* @param screenshot
*/
public void showAlert(Bitmap screenshot) {
AlertDialog.Builder alertadd = new AlertDialog.Builder(QuizResultActivity.this);
LayoutInflater factory = LayoutInflater.from(QuizResultActivity.this);
final View view = factory.inflate(R.layout.image_alert_layout, null);
ImageView screenShotImage = view.findViewById(R.id.alert_image);
screenShotImage.setImageBitmap(screenshot);
TextView shareMessage = view.findViewById(R.id.alert_text);
shareMessage.setText(R.string.quiz_result_share_message);
alertadd.setView(view);
alertadd.setPositiveButton(R.string.about_translate_proceed, (dialog, which) -> shareScreen(screenshot));
alertadd.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel());
alertadd.show();
}
}

View file

@ -0,0 +1,192 @@
package fr.free.nrw.commons.quiz
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import fr.free.nrw.commons.databinding.ActivityQuizResultBinding
import java.io.File
import java.io.FileOutputStream
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.MainActivity
/**
* Displays the final score of quiz and congratulates the user
*/
class QuizResultActivity : AppCompatActivity() {
private var binding: ActivityQuizResultBinding? = null
private val NUMBER_OF_QUESTIONS = 5
private val MULTIPLIER_TO_GET_PERCENTAGE = 20
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityQuizResultBinding.inflate(layoutInflater)
setContentView(binding?.root)
setSupportActionBar(binding?.toolbar?.toolbar)
binding?.quizResultNext?.setOnClickListener {
launchContributionActivity()
}
intent?.extras?.let { extras ->
val score = extras.getInt("QuizResult", 0)
setScore(score)
} ?: run {
startActivityWithFlags(
this, MainActivity::class.java,
Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP
)
super.onBackPressed()
}
}
override fun onDestroy() {
binding = null
super.onDestroy()
}
/**
* To calculate and display percentage and score
* @param score
*/
@SuppressLint("StringFormatInvalid", "SetTextI18n")
fun setScore(score: Int) {
val scorePercent = score * MULTIPLIER_TO_GET_PERCENTAGE
binding?.resultProgressBar?.progress = scorePercent
binding?.tvResultProgress?.text = "$score / $NUMBER_OF_QUESTIONS"
val message = resources.getString(R.string.congratulatory_message_quiz, "$scorePercent%")
binding?.congratulatoryMessage?.text = message
}
/**
* To go to Contributions Activity
*/
fun launchContributionActivity() {
startActivityWithFlags(
this, MainActivity::class.java,
Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP
)
}
override fun onBackPressed() {
startActivityWithFlags(
this, MainActivity::class.java,
Intent.FLAG_ACTIVITY_CLEAR_TOP, Intent.FLAG_ACTIVITY_SINGLE_TOP
)
super.onBackPressed()
}
/**
* Function to call intent to an activity
* @param context
* @param cls
* @param flags
*/
companion object {
fun <T> startActivityWithFlags(context: Context, cls: Class<T>, vararg flags: Int) {
val intent = Intent(context, cls)
flags.forEach { flag -> intent.addFlags(flag) }
context.startActivity(intent)
}
}
/**
* To inflate menu
* @param menu
* @return
*/
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_about, menu)
return true
}
/**
* If share option selected then take screenshot and launch alert
* @param item
* @return
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.share_app_icon) {
val rootView = window.decorView.findViewById<View>(android.R.id.content)
val screenShot = getScreenShot(rootView)
showAlert(screenShot)
}
return super.onOptionsItemSelected(item)
}
/**
* To store the screenshot of image in bitmap variable temporarily
* @param view
* @return
*/
fun getScreenShot(view: View): Bitmap {
val screenView = view.rootView
screenView.isDrawingCacheEnabled = true
val bitmap = Bitmap.createBitmap(screenView.drawingCache)
screenView.isDrawingCacheEnabled = false
return bitmap
}
/**
* Share the screenshot through social media
* @param bitmap
*/
@SuppressLint("SetWorldReadable")
fun shareScreen(bitmap: Bitmap) {
try {
val file = File(this.externalCacheDir, "screen.png")
FileOutputStream(file).use { fOut ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut)
fOut.flush()
}
file.setReadable(true, false)
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file))
type = "image/png"
}
startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)))
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* It displays the AlertDialog with Image of screenshot
* @param screenshot
*/
fun showAlert(screenshot: Bitmap) {
val alertadd = AlertDialog.Builder(this)
val factory = LayoutInflater.from(this)
val view = factory.inflate(R.layout.image_alert_layout, null)
val screenShotImage = view.findViewById<ImageView>(R.id.alert_image)
screenShotImage.setImageBitmap(screenshot)
val shareMessage = view.findViewById<TextView>(R.id.alert_text)
shareMessage.setText(R.string.quiz_result_share_message)
alertadd.setView(view)
alertadd.setPositiveButton(R.string.about_translate_proceed) { dialog, _ ->
shareScreen(screenshot)
}
alertadd.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.cancel()
}
alertadd.show()
}
}

View file

@ -1,64 +0,0 @@
package fr.free.nrw.commons.quiz;
import android.app.Activity;
import android.view.View;
import android.widget.CompoundButton;
import android.widget.RadioButton;
import java.util.ArrayList;
import java.util.List;
/**
* Used to group to or more radio buttons to ensure
* that at a particular time only one of them is selected
*/
public class RadioGroupHelper {
public List<CompoundButton> radioButtons = new ArrayList<>();
/**
* Constructor to group radio buttons
* @param radios
*/
public RadioGroupHelper(RadioButton... radios) {
super();
for (RadioButton rb : radios) {
add(rb);
}
}
/**
* Constructor to group radio buttons
* @param activity
* @param radiosIDs
*/
public RadioGroupHelper(Activity activity, int... radiosIDs) {
this(activity.findViewById(android.R.id.content),radiosIDs);
}
/**
* Constructor to group radio buttons
* @param rootView
* @param radiosIDs
*/
public RadioGroupHelper(View rootView, int... radiosIDs) {
super();
for (int radioButtonID : radiosIDs) {
add(rootView.findViewById(radioButtonID));
}
}
private void add(CompoundButton button){
this.radioButtons.add(button);
button.setOnClickListener(onClickListener);
}
/**
* listener to ensure only one of the radio button is selected
*/
View.OnClickListener onClickListener = v -> {
for (CompoundButton rb : radioButtons) {
if (rb != v) rb.setChecked(false);
}
};
}

View file

@ -0,0 +1,61 @@
package fr.free.nrw.commons.quiz
import android.app.Activity
import android.view.View
import android.widget.CompoundButton
import android.widget.RadioButton
import java.util.ArrayList
/**
* Used to group to or more radio buttons to ensure
* that at a particular time only one of them is selected
*/
class RadioGroupHelper {
val radioButtons: MutableList<CompoundButton> = ArrayList()
/**
* Constructor to group radio buttons
* @param radios
*/
constructor(vararg radios: RadioButton) {
for (rb in radios) {
add(rb)
}
}
/**
* Constructor to group radio buttons
* @param activity
* @param radiosIDs
*/
constructor(activity: Activity, vararg radiosIDs: Int) : this(
*radiosIDs.map { id -> activity.findViewById<RadioButton>(id) }.toTypedArray()
)
/**
* Constructor to group radio buttons
* @param rootView
* @param radiosIDs
*/
constructor(rootView: View, vararg radiosIDs: Int) {
for (radioButtonID in radiosIDs) {
add(rootView.findViewById(radioButtonID))
}
}
private fun add(button: CompoundButton) {
radioButtons.add(button)
button.setOnClickListener(onClickListener)
}
/**
* listener to ensure only one of the radio button is selected
*/
private val onClickListener = View.OnClickListener { v ->
for (rb in radioButtons) {
if (rb != v) rb.isChecked = false
}
}
}

View file

@ -1,122 +0,0 @@
package fr.free.nrw.commons.recentlanguages;
import static fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.COLUMN_NAME;
import static fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.TABLE_NAME;
import android.content.ContentValues;
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 javax.inject.Inject;
import timber.log.Timber;
/**
* Content provider of recently used languages
*/
public class RecentLanguagesContentProvider extends CommonsDaggerContentProvider {
private static final String BASE_PATH = "recent_languages";
public static final Uri BASE_URI =
Uri.parse("content://" + BuildConfig.RECENT_LANGUAGE_AUTHORITY + "/" + BASE_PATH);
/**
* Append language code to the base uri
* @param languageCode Code of a language
*/
public static Uri uriForCode(final String languageCode) {
return Uri.parse(BASE_URI + "/" + languageCode);
}
@Inject
DBOpenHelper dbOpenHelper;
@Override
public String getType(@NonNull final Uri uri) {
return null;
}
/**
* Queries the SQLite database for the recently used languages
* @param uri : contains the uri for recently used languages
* @param projection : contains the all fields of the table
* @param selection : handles Where
* @param selectionArgs : the condition of Where clause
* @param sortOrder : ascending or descending
*/
@Override
public Cursor query(@NonNull final Uri uri, final String[] projection, final String selection,
final String[] selectionArgs, final String sortOrder) {
final SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(TABLE_NAME);
final SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
final Cursor cursor = queryBuilder.query(db, projection, selection,
selectionArgs, null, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
/**
* Handles the update query of local SQLite Database
* @param uri : contains the uri for recently used languages
* @param contentValues : new values to be entered to db
* @param selection : handles Where
* @param selectionArgs : the condition of Where clause
*/
@Override
public int update(@NonNull final Uri uri, final ContentValues contentValues,
final String selection, final String[] selectionArgs) {
final SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
final int rowsUpdated;
if (TextUtils.isEmpty(selection)) {
final int id = Integer.parseInt(uri.getLastPathSegment());
rowsUpdated = sqlDB.update(TABLE_NAME,
contentValues,
COLUMN_NAME + " = ?",
new String[]{String.valueOf(id)});
} else {
throw new IllegalArgumentException(
"Parameter `selection` should be empty when updating an ID");
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsUpdated;
}
/**
* Handles the insertion of new recently used languages record to local SQLite Database
* @param uri : contains the uri for recently used languages
* @param contentValues : new values to be entered to db
*/
@Override
public Uri insert(@NonNull final Uri uri, final ContentValues contentValues) {
final SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
final long id = sqlDB.insert(TABLE_NAME, null, contentValues);
getContext().getContentResolver().notifyChange(uri, null);
return Uri.parse(BASE_URI + "/" + id);
}
/**
* Handles the deletion of new recently used languages record to local SQLite Database
* @param uri : contains the uri for recently used languages
*/
@Override
public int delete(@NonNull final Uri uri, final String s, final String[] strings) {
final int rows;
final SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Timber.d("Deleting recently used language %s", uri.getLastPathSegment());
rows = db.delete(
TABLE_NAME,
"language_code = ?",
new String[]{uri.getLastPathSegment()}
);
getContext().getContentResolver().notifyChange(uri, null);
return rows;
}
}

View file

@ -0,0 +1,142 @@
package fr.free.nrw.commons.recentlanguages
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteQueryBuilder
import android.net.Uri
import android.text.TextUtils
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.di.CommonsDaggerContentProvider
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.COLUMN_NAME
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao.Table.TABLE_NAME
import javax.inject.Inject
import timber.log.Timber
/**
* Content provider of recently used languages
*/
class RecentLanguagesContentProvider : CommonsDaggerContentProvider() {
companion object {
private const val BASE_PATH = "recent_languages"
val BASE_URI: Uri =
Uri.parse(
"content://${BuildConfig.RECENT_LANGUAGE_AUTHORITY}/$BASE_PATH"
)
/**
* Append language code to the base URI
* @param languageCode Code of a language
*/
@JvmStatic
fun uriForCode(languageCode: String): Uri {
return Uri.parse("$BASE_URI/$languageCode")
}
}
@Inject
lateinit var dbOpenHelper: DBOpenHelper
override fun getType(uri: Uri): String? {
return null
}
/**
* Queries the SQLite database for the recently used languages
* @param uri : contains the URI for recently used languages
* @param projection : contains all fields of the table
* @param selection : handles WHERE
* @param selectionArgs : the condition of WHERE clause
* @param sortOrder : ascending or descending
*/
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? {
val queryBuilder = SQLiteQueryBuilder()
queryBuilder.tables = TABLE_NAME
val db = dbOpenHelper.readableDatabase
val cursor = queryBuilder.query(
db,
projection,
selection,
selectionArgs,
null,
null,
sortOrder
)
cursor.setNotificationUri(context?.contentResolver, uri)
return cursor
}
/**
* Handles the update query of local SQLite Database
* @param uri : contains the URI for recently used languages
* @param contentValues : new values to be entered to the database
* @param selection : handles WHERE
* @param selectionArgs : the condition of WHERE clause
*/
override fun update(
uri: Uri,
contentValues: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int {
val sqlDB = dbOpenHelper.writableDatabase
val rowsUpdated: Int
if (selection.isNullOrEmpty()) {
val id = uri.lastPathSegment?.toInt()
?: throw IllegalArgumentException("Invalid URI: $uri")
rowsUpdated = sqlDB.update(
TABLE_NAME,
contentValues,
"$COLUMN_NAME = ?",
arrayOf(id.toString())
)
} else {
throw IllegalArgumentException("Parameter `selection` should be empty when updating an ID")
}
context?.contentResolver?.notifyChange(uri, null)
return rowsUpdated
}
/**
* Handles the insertion of new recently used languages record to local SQLite Database
* @param uri : contains the URI for recently used languages
* @param contentValues : new values to be entered to the database
*/
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? {
val sqlDB = dbOpenHelper.writableDatabase
val id = sqlDB.insert(
TABLE_NAME,
null,
contentValues
)
context?.contentResolver?.notifyChange(uri, null)
return Uri.parse("$BASE_URI/$id")
}
/**
* Handles the deletion of a recently used languages record from local SQLite Database
* @param uri : contains the URI for recently used languages
*/
override fun delete(uri: Uri, s: String?, strings: Array<String>?): Int {
val db = dbOpenHelper.readableDatabase
Timber.d("Deleting recently used language %s", uri.lastPathSegment)
val rows = db.delete(
TABLE_NAME,
"language_code = ?",
arrayOf(uri.lastPathSegment)
)
context?.contentResolver?.notifyChange(uri, null)
return rows
}
}

View file

@ -1,204 +0,0 @@
package fr.free.nrw.commons.recentlanguages;
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 java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import javax.inject.Singleton;
/**
* Handles database operations for recently used languages
*/
@Singleton
public class RecentLanguagesDao {
private final Provider<ContentProviderClient> clientProvider;
@Inject
public RecentLanguagesDao
(@Named("recent_languages") final Provider<ContentProviderClient> clientProvider) {
this.clientProvider = clientProvider;
}
/**
* Find all persisted recently used languages on database
* @return list of recently used languages
*/
public List<Language> getRecentLanguages() {
final List<Language> languages = new ArrayList<>();
final ContentProviderClient db = clientProvider.get();
try (final Cursor cursor = db.query(
RecentLanguagesContentProvider.BASE_URI,
RecentLanguagesDao.Table.ALL_FIELDS,
null,
new String[]{},
null)) {
if(cursor != null && cursor.moveToLast()) {
do {
languages.add(fromCursor(cursor));
} while (cursor.moveToPrevious());
}
} catch (final RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
return languages;
}
/**
* Add a Language to database
* @param language : Language to add
*/
public void addRecentLanguage(final Language language) {
final ContentProviderClient db = clientProvider.get();
try {
db.insert(RecentLanguagesContentProvider.BASE_URI, toContentValues(language));
} catch (final RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
/**
* Delete a language from database
* @param languageCode : code of the Language to delete
*/
public void deleteRecentLanguage(final String languageCode) {
final ContentProviderClient db = clientProvider.get();
try {
db.delete(RecentLanguagesContentProvider.uriForCode(languageCode), null, null);
} catch (final RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
/**
* Find a language from database based on its name
* @param languageCode : code of the Language to find
* @return boolean : is language in database ?
*/
public boolean findRecentLanguage(final String languageCode) {
if (languageCode == null) { //Avoiding NPE's
return false;
}
final ContentProviderClient db = clientProvider.get();
try (final Cursor cursor = db.query(
RecentLanguagesContentProvider.BASE_URI,
RecentLanguagesDao.Table.ALL_FIELDS,
Table.COLUMN_CODE + "=?",
new String[]{languageCode},
null
)) {
if (cursor != null && cursor.moveToFirst()) {
return true;
}
} catch (final RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
return false;
}
/**
* It creates an Recent Language object from data stored in the SQLite DB by using cursor
* @param cursor cursor
* @return Language object
*/
@NonNull
@SuppressLint("Range")
Language fromCursor(final Cursor cursor) {
// Hardcoding column positions!
final String languageName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME));
final String languageCode = cursor.getString(cursor.getColumnIndex(Table.COLUMN_CODE));
return new Language(languageName, languageCode);
}
/**
* Takes data from Language and create a content value object
* @param recentLanguage recently used language
* @return ContentValues
*/
private ContentValues toContentValues(final Language recentLanguage) {
final ContentValues cv = new ContentValues();
cv.put(Table.COLUMN_NAME, recentLanguage.getLanguageName());
cv.put(Table.COLUMN_CODE, recentLanguage.getLanguageCode());
return cv;
}
/**
* This class contains the database table architecture for recently used languages,
* It also contains queries and logic necessary to the create, update, delete this table.
*/
public static final class Table {
public static final String TABLE_NAME = "recent_languages";
static final String COLUMN_NAME = "language_name";
static final String COLUMN_CODE = "language_code";
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
public static final String[] ALL_FIELDS = {
COLUMN_NAME,
COLUMN_CODE
};
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ COLUMN_NAME + " STRING,"
+ COLUMN_CODE + " STRING PRIMARY KEY"
+ ");";
/**
* This method creates a LanguagesTable in SQLiteDatabase
* @param db SQLiteDatabase
*/
public static void onCreate(final SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
}
/**
* This method deletes LanguagesTable from SQLiteDatabase
* @param db SQLiteDatabase
*/
public static void onDelete(final SQLiteDatabase db) {
db.execSQL(DROP_TABLE_STATEMENT);
onCreate(db);
}
/**
* This method is called on migrating from a older version to a newer version
* @param db SQLiteDatabase
* @param from Version from which we are migrating
* @param to Version to which we are migrating
*/
public static void onUpdate(final SQLiteDatabase db, int from, final int to) {
if (from == to) {
return;
}
if (from < 19) {
// doesn't exist yet
from++;
onUpdate(db, from, to);
return;
}
if (from == 19) {
// table added in version 20
onCreate(db);
from++;
onUpdate(db, from, to);
}
}
}
}

View file

@ -0,0 +1,215 @@
package fr.free.nrw.commons.recentlanguages
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 javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
import javax.inject.Singleton
/**
* Handles database operations for recently used languages
*/
@Singleton
class RecentLanguagesDao @Inject constructor(
@Named("recent_languages")
private val clientProvider: Provider<ContentProviderClient>
) {
/**
* Find all persisted recently used languages on database
* @return list of recently used languages
*/
fun getRecentLanguages(): List<Language> {
val languages = mutableListOf<Language>()
val db = clientProvider.get()
try {
db.query(
RecentLanguagesContentProvider.BASE_URI,
Table.ALL_FIELDS,
null,
arrayOf(),
null
)?.use { cursor ->
if (cursor.moveToLast()) {
do {
languages.add(fromCursor(cursor))
} while (cursor.moveToPrevious())
}
}
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
db.release()
}
return languages
}
/**
* Add a Language to database
* @param language : Language to add
*/
fun addRecentLanguage(language: Language) {
val db = clientProvider.get()
try {
db.insert(
RecentLanguagesContentProvider.BASE_URI,
toContentValues(language)
)
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
db.release()
}
}
/**
* Delete a language from database
* @param languageCode : code of the Language to delete
*/
fun deleteRecentLanguage(languageCode: String) {
val db = clientProvider.get()
try {
db.delete(
RecentLanguagesContentProvider.uriForCode(languageCode),
null,
null
)
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
db.release()
}
}
/**
* Find a language from database based on its name
* @param languageCode : code of the Language to find
* @return boolean : is language in database ?
*/
fun findRecentLanguage(languageCode: String?): Boolean {
if (languageCode == null) { // Avoiding NPEs
return false
}
val db = clientProvider.get()
try {
db.query(
RecentLanguagesContentProvider.BASE_URI,
Table.ALL_FIELDS,
"${Table.COLUMN_CODE}=?",
arrayOf(languageCode),
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
return true
}
}
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
db.release()
}
return false
}
/**
* It creates an Recent Language object from data stored in the SQLite DB by using cursor
* @param cursor cursor
* @return Language object
*/
@SuppressLint("Range")
fun fromCursor(cursor: Cursor): Language {
// Hardcoding column positions!
val languageName = cursor.getString(
cursor.getColumnIndex(Table.COLUMN_NAME)
)
val languageCode = cursor.getString(
cursor.getColumnIndex(Table.COLUMN_CODE)
)
return Language(languageName, languageCode)
}
/**
* Takes data from Language and create a content value object
* @param recentLanguage recently used language
* @return ContentValues
*/
private fun toContentValues(recentLanguage: Language): ContentValues {
return ContentValues().apply {
put(Table.COLUMN_NAME, recentLanguage.languageName)
put(Table.COLUMN_CODE, recentLanguage.languageCode)
}
}
/**
* This class contains the database table architecture for recently used languages,
* It also contains queries and logic necessary to the create, update, delete this table.
*/
object Table {
const val TABLE_NAME = "recent_languages"
const val COLUMN_NAME = "language_name"
const val COLUMN_CODE = "language_code"
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
@JvmStatic
val ALL_FIELDS = arrayOf(
COLUMN_NAME,
COLUMN_CODE
)
const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
"$COLUMN_NAME STRING," +
"$COLUMN_CODE STRING PRIMARY KEY" +
");"
/**
* This method creates a LanguagesTable in SQLiteDatabase
* @param db SQLiteDatabase
*/
@SuppressLint("SQLiteString")
@JvmStatic
fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_TABLE_STATEMENT)
}
/**
* This method deletes LanguagesTable from SQLiteDatabase
* @param db SQLiteDatabase
*/
@JvmStatic
fun onDelete(db: SQLiteDatabase) {
db.execSQL(DROP_TABLE_STATEMENT)
onCreate(db)
}
/**
* This method is called on migrating from a older version to a newer version
* @param db SQLiteDatabase
* @param from Version from which we are migrating
* @param to Version to which we are migrating
*/
@JvmStatic
fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
if (from == to) {
return
}
if (from < 19) {
// doesn't exist yet
onUpdate(db, from + 1, to)
return
}
if (from == 19) {
// table added in version 20
onCreate(db)
onUpdate(db, from + 1, to)
}
}
}
}

View file

@ -1,423 +0,0 @@
package fr.free.nrw.commons.repository;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.category.CategoriesModel;
import fr.free.nrw.commons.category.CategoryItem;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.NearbyPlaces;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.upload.ImageCoordinates;
import fr.free.nrw.commons.upload.SimilarImageInterface;
import fr.free.nrw.commons.upload.UploadController;
import fr.free.nrw.commons.upload.UploadItem;
import fr.free.nrw.commons.upload.UploadModel;
import fr.free.nrw.commons.upload.structure.depictions.DepictModel;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import io.reactivex.Flowable;
import io.reactivex.Observable;
import io.reactivex.Single;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import timber.log.Timber;
/**
* The repository class for UploadActivity
*/
@Singleton
public class UploadRepository {
private final UploadModel uploadModel;
private final UploadController uploadController;
private final CategoriesModel categoriesModel;
private final NearbyPlaces nearbyPlaces;
private final DepictModel depictModel;
private static final double NEARBY_RADIUS_IN_KILO_METERS = 0.1; //100 meters
private final ContributionDao contributionDao;
@Inject
public UploadRepository(UploadModel uploadModel,
UploadController uploadController,
CategoriesModel categoriesModel,
NearbyPlaces nearbyPlaces,
DepictModel depictModel,
ContributionDao contributionDao) {
this.uploadModel = uploadModel;
this.uploadController = uploadController;
this.categoriesModel = categoriesModel;
this.nearbyPlaces = nearbyPlaces;
this.depictModel = depictModel;
this.contributionDao=contributionDao;
}
/**
* asks the RemoteDataSource to build contributions
*
* @return
*/
public Observable<Contribution> buildContributions() {
return uploadModel.buildContributions();
}
/**
* asks the RemoteDataSource to start upload for the contribution
*
* @param contribution
*/
public void prepareMedia(Contribution contribution) {
uploadController.prepareMedia(contribution);
}
public void saveContribution(Contribution contribution) {
contributionDao.save(contribution).blockingAwait();
}
/**
* Fetches and returns all the Upload Items
*
* @return
*/
public List<UploadItem> getUploads() {
return uploadModel.getUploads();
}
/**
*Prepare for a fresh upload
*/
public void cleanup() {
uploadModel.cleanUp();
//This needs further refactoring, this should not be here, right now the structure wont suppoort rhis
categoriesModel.cleanUp();
depictModel.cleanUp();
}
/**
* Fetches and returns the selected categories for the current upload
*
* @return
*/
public List<CategoryItem> getSelectedCategories() {
return categoriesModel.getSelectedCategories();
}
/**
* all categories from MWApi
*
* @param query
* @param imageTitleList
* @param selectedDepictions
* @return
*/
public Observable<List<CategoryItem>> searchAll(String query, List<String> imageTitleList,
List<DepictedItem> selectedDepictions) {
return categoriesModel.searchAll(query, imageTitleList, selectedDepictions);
}
/**
* sets the list of selected categories for the current upload
*
* @param categoryStringList
*/
public void setSelectedCategories(List<String> categoryStringList) {
uploadModel.setSelectedCategories(categoryStringList);
}
/**
* handles the category selection/deselection
*
* @param categoryItem
*/
public void onCategoryClicked(CategoryItem categoryItem, final Media media) {
categoriesModel.onCategoryItemClicked(categoryItem, media);
}
/**
* prunes the category list for irrelevant categories see #750
*
* @param name
* @return
*/
public boolean isSpammyCategory(String name) {
return categoriesModel.isSpammyCategory(name);
}
/**
* retursn the string list of available license from the LocalDataSource
*
* @return
*/
public List<String> getLicenses() {
return uploadModel.getLicenses();
}
/**
* returns the selected license for the current upload
*
* @return
*/
public String getSelectedLicense() {
return uploadModel.getSelectedLicense();
}
/**
* returns the number of Upload Items
*
* @return
*/
public int getCount() {
return uploadModel.getCount();
}
/**
* ask the RemoteDataSource to pre process the image
*
* @param uploadableFile
* @param place
* @param similarImageInterface
* @return
*/
public Observable<UploadItem> preProcessImage(UploadableFile uploadableFile, Place place,
SimilarImageInterface similarImageInterface, LatLng inAppPictureLocation) {
return uploadModel.preProcessImage(uploadableFile, place,
similarImageInterface, inAppPictureLocation);
}
/**
* query the RemoteDataSource for image quality
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of UploadItem
*/
public Single<Integer> getImageQuality(UploadItem uploadItem, LatLng location) {
return uploadModel.getImageQuality(uploadItem, location);
}
/**
* query the RemoteDataSource for image duplicity check
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
public Single<Integer> checkDuplicateImage(String filePath) {
return uploadModel.checkDuplicateImage(filePath);
}
/**
* query the RemoteDataSource for caption quality
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
public Single<Integer> getCaptionQuality(UploadItem uploadItem) {
return uploadModel.getCaptionQuality(uploadItem);
}
/**
* asks the LocalDataSource to delete the file with the given file path
*
* @param filePath
*/
public void deletePicture(String filePath) {
uploadModel.deletePicture(filePath);
}
/**
* fetches and returns the upload item
*
* @param index
* @return
*/
public UploadItem getUploadItem(int index) {
if (index >= 0) {
return uploadModel.getItems().get(index);
}
return null; //There is no item to copy details
}
/**
* set selected license for the current upload
*
* @param licenseName
*/
public void setSelectedLicense(String licenseName) {
uploadModel.setSelectedLicense(licenseName);
}
public void onDepictItemClicked(DepictedItem depictedItem, final Media media) {
uploadModel.onDepictItemClicked(depictedItem, media);
}
/**
* Fetches and returns the selected depictions for the current upload
*
* @return
*/
public List<DepictedItem> getSelectedDepictions() {
return uploadModel.getSelectedDepictions();
}
/**
* Provides selected existing depicts
*
* @return selected existing depicts
*/
public List<String> getSelectedExistingDepictions() {
return uploadModel.getSelectedExistingDepictions();
}
/**
* Initialize existing depicts
*
* @param selectedExistingDepictions existing depicts
*/
public void setSelectedExistingDepictions(final List<String> selectedExistingDepictions) {
uploadModel.setSelectedExistingDepictions(selectedExistingDepictions);
}
/**
* Search all depictions from
*
* @param query
* @return
*/
public Flowable<List<DepictedItem>> searchAllEntities(String query) {
return depictModel.searchAllEntities(query, this);
}
/**
* Gets the depiction for each unique {@link Place} associated with an {@link UploadItem}
* from {@link #getUploads()}
*
* @return a single that provides the depictions
*/
public Single<List<DepictedItem>> getPlaceDepictions() {
final Set<String> qids = new HashSet<>();
for (final UploadItem item : getUploads()) {
final Place place = item.getPlace();
if (place != null) {
qids.add(place.getWikiDataEntityId());
}
}
return depictModel.getPlaceDepictions(new ArrayList<>(qids));
}
/**
* Gets the category for each unique {@link Place} associated with an {@link UploadItem}
* from {@link #getUploads()}
*
* @return a single that provides the categories
*/
public Single<List<CategoryItem>> getPlaceCategories() {
final Set<String> qids = new HashSet<>();
for (final UploadItem item : getUploads()) {
final Place place = item.getPlace();
if (place != null) {
qids.add(place.getCategory());
}
}
return Single.fromObservable(categoriesModel.getCategoriesByName(new ArrayList<>(qids)));
}
/**
* Takes depict IDs as a parameter, converts into a slash separated String and Gets DepictItem
* from the server
*
* @param depictionsQIDs IDs of Depiction
* @return Flowable<List<DepictedItem>>
*/
public Flowable<List<DepictedItem>> getDepictions(final List<String> depictionsQIDs){
final String ids = joinQIDs(depictionsQIDs);
return depictModel.getDepictions(ids).toFlowable();
}
/**
* Builds a string by joining all IDs divided by "|"
*
* @param depictionsQIDs IDs of depiction ex. ["Q11023","Q1356"]
* @return string ex. "Q11023|Q1356"
*/
private String joinQIDs(final List<String> depictionsQIDs) {
if (depictionsQIDs != null && !depictionsQIDs.isEmpty()) {
final StringBuilder buffer = new StringBuilder(depictionsQIDs.get(0));
if (depictionsQIDs.size() > 1) {
for (int i = 1; i < depictionsQIDs.size(); i++) {
buffer.append("|");
buffer.append(depictionsQIDs.get(i));
}
}
return buffer.toString();
}
return null;
}
/**
* Returns nearest place matching the passed latitude and longitude
*
* @param decLatitude
* @param decLongitude
* @return
*/
@Nullable
public Place checkNearbyPlaces(final double decLatitude, final double decLongitude) {
try {
final List<Place> fromWikidataQuery = nearbyPlaces.getFromWikidataQuery(new LatLng(
decLatitude, decLongitude, 0.0f),
Locale.getDefault().getLanguage(),
NEARBY_RADIUS_IN_KILO_METERS, null);
return (fromWikidataQuery != null && fromWikidataQuery.size() > 0) ? fromWikidataQuery
.get(0) : null;
} catch (final Exception e) {
Timber.e("Error fetching nearby places: %s", e.getMessage());
return null;
}
}
public void useSimilarPictureCoordinates(ImageCoordinates imageCoordinates, int uploadItemIndex) {
uploadModel.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex);
}
public boolean isWMLSupportedForThisPlace() {
return uploadModel.getItems().get(0).isWLMUpload();
}
/**
* Provides selected existing categories
*
* @return selected existing categories
*/
public List<String> getSelectedExistingCategories() {
return categoriesModel.getSelectedExistingCategories();
}
/**
* Initialize existing categories
*
* @param selectedExistingCategories existing categories
*/
public void setSelectedExistingCategories(final List<String> selectedExistingCategories) {
categoriesModel.setSelectedExistingCategories(selectedExistingCategories);
}
/**
* Takes category names and Gets CategoryItem from the server
*
* @param categories names of Category
* @return Observable<List<CategoryItem>>
*/
public Observable<List<CategoryItem>> getCategories(final List<String> categories){
return categoriesModel.getCategoriesByName(categories);
}
}

View file

@ -0,0 +1,410 @@
package fr.free.nrw.commons.repository
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.category.CategoriesModel
import fr.free.nrw.commons.category.CategoryItem
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.NearbyPlaces
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.SimilarImageInterface
import fr.free.nrw.commons.upload.UploadController
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadModel
import fr.free.nrw.commons.upload.structure.depictions.DepictModel
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Flowable
import io.reactivex.Observable
import io.reactivex.Single
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* The repository class for UploadActivity
*/
@Singleton
class UploadRepository @Inject constructor(
private val uploadModel: UploadModel,
private val uploadController: UploadController,
private val categoriesModel: CategoriesModel,
private val nearbyPlaces: NearbyPlaces,
private val depictModel: DepictModel,
private val contributionDao: ContributionDao
) {
companion object {
private const val NEARBY_RADIUS_IN_KILO_METERS = 0.1 // 100 meters
}
/**
* Asks the RemoteDataSource to build contributions
*
* @return
*/
fun buildContributions(): Observable<Contribution>? {
return uploadModel.buildContributions()
}
/**
* Asks the RemoteDataSource to start upload for the contribution
*
* @param contribution
*/
fun prepareMedia(contribution: Contribution) {
uploadController.prepareMedia(contribution)
}
fun saveContribution(contribution: Contribution) {
contributionDao.save(contribution).blockingAwait()
}
/**
* Fetches and returns all the Upload Items
*
* @return
*/
fun getUploads(): List<UploadItem> {
return uploadModel.getUploads()
}
/**
* Prepare for a fresh upload
*/
fun cleanup() {
uploadModel.cleanUp()
// This needs further refactoring, this should not be here, right now the structure
// won't support this
categoriesModel.cleanUp()
depictModel.cleanUp()
}
/**
* Fetches and returns the selected categories for the current upload
*
* @return
*/
fun getSelectedCategories(): List<CategoryItem> {
return categoriesModel.getSelectedCategories()
}
/**
* All categories from MWApi
*
* @param query
* @param imageTitleList
* @param selectedDepictions
* @return
*/
fun searchAll(
query: String,
imageTitleList: List<String>,
selectedDepictions: List<DepictedItem>
): Observable<List<CategoryItem>> {
return categoriesModel.searchAll(query, imageTitleList, selectedDepictions)
}
/**
* Sets the list of selected categories for the current upload
*
* @param categoryStringList
*/
fun setSelectedCategories(categoryStringList: List<String>) {
uploadModel.setSelectedCategories(categoryStringList)
}
/**
* Handles the category selection/deselection
*
* @param categoryItem
*/
fun onCategoryClicked(categoryItem: CategoryItem, media: Media?) {
categoriesModel.onCategoryItemClicked(categoryItem, media)
}
/**
* Prunes the category list for irrelevant categories see #750
*
* @param name
* @return
*/
fun isSpammyCategory(name: String): Boolean {
return categoriesModel.isSpammyCategory(name)
}
/**
* Returns the string list of available licenses from the LocalDataSource
*
* @return
*/
fun getLicenses(): List<String> {
return uploadModel.licenses
}
/**
* Returns the selected license for the current upload
*
* @return
*/
fun getSelectedLicense(): String? {
return uploadModel.selectedLicense
}
/**
* Returns the number of Upload Items
*
* @return
*/
fun getCount(): Int {
return uploadModel.count
}
/**
* Ask the RemoteDataSource to preprocess the image
*
* @param uploadableFile
* @param place
* @param similarImageInterface
* @param inAppPictureLocation
* @return
*/
fun preProcessImage(
uploadableFile: UploadableFile?,
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): Observable<UploadItem>? {
return uploadModel.preProcessImage(
uploadableFile,
place,
similarImageInterface,
inAppPictureLocation
)
}
/**
* Query the RemoteDataSource for image quality
*
* @param uploadItem UploadItem whose caption is to be checked
* @param location Location of the image
* @return Quality of UploadItem
*/
fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int>? {
return uploadModel.getImageQuality(uploadItem, location)
}
/**
* Query the RemoteDataSource for image duplicity check
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String): Single<Int> {
return uploadModel.checkDuplicateImage(filePath)
}
/**
* query the RemoteDataSource for caption quality
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
fun getCaptionQuality(uploadItem: UploadItem): Single<Int>? {
return uploadModel.getCaptionQuality(uploadItem)
}
/**
* asks the LocalDataSource to delete the file with the given file path
*
* @param filePath
*/
fun deletePicture(filePath: String) {
uploadModel.deletePicture(filePath)
}
/**
* fetches and returns the upload item
*
* @param index
* @return
*/
fun getUploadItem(index: Int): UploadItem? {
return if (index >= 0) {
uploadModel.items.getOrNull(index)
} else null //There is no item to copy details
}
/**
* set selected license for the current upload
*
* @param licenseName
*/
fun setSelectedLicense(licenseName: String) {
uploadModel.selectedLicense = licenseName
}
fun onDepictItemClicked(depictedItem: DepictedItem, media: Media?) {
uploadModel.onDepictItemClicked(depictedItem, media)
}
/**
* Fetches and returns the selected depictions for the current upload
*
* @return
*/
fun getSelectedDepictions(): List<DepictedItem> {
return uploadModel.selectedDepictions
}
/**
* Provides selected existing depicts
*
* @return selected existing depicts
*/
fun getSelectedExistingDepictions(): List<String> {
return uploadModel.selectedExistingDepictions
}
/**
* Initialize existing depicts
*
* @param selectedExistingDepictions existing depicts
*/
fun setSelectedExistingDepictions(selectedExistingDepictions: List<String>) {
uploadModel.selectedExistingDepictions = selectedExistingDepictions
}
/**
* Search all depictions from
*
* @param query
* @return
*/
fun searchAllEntities(query: String): Flowable<List<DepictedItem>> {
return depictModel.searchAllEntities(query, this)
}
/**
* Gets the depiction for each unique {@link Place} associated with an {@link UploadItem}
* from {@link #getUploads()}
*
* @return a single that provides the depictions
*/
fun getPlaceDepictions(): Single<List<DepictedItem>> {
val qids = mutableSetOf<String>()
getUploads().forEach { item ->
item.place?.let {
it.wikiDataEntityId?.let { it1 ->
qids.add(it1)
}
}
}
return depictModel.getPlaceDepictions(qids.toList())
}
/**
* Gets the category for each unique {@link Place} associated with an {@link UploadItem}
* from {@link #getUploads()}
*
* @return a single that provides the categories
*/
fun getPlaceCategories(): Single<List<CategoryItem>> {
val qids = mutableSetOf<String>()
getUploads().forEach { item ->
item.place?.category?.let { qids.add(it) }
}
return Single.fromObservable(categoriesModel.getCategoriesByName(qids.toList()))
}
/**
* Takes depict IDs as a parameter, converts into a slash separated String and Gets DepictItem
* from the server
*
* @param depictionsQIDs IDs of Depiction
* @return Flowable<List<DepictedItem>>
*/
fun getDepictions(depictionsQIDs: List<String>): Flowable<List<DepictedItem>> {
val ids = joinQIDs(depictionsQIDs) ?: ""
return depictModel.getDepictions(ids).toFlowable()
}
/**
* Builds a string by joining all IDs divided by "|"
*
* @param depictionsQIDs IDs of depiction ex. ["Q11023","Q1356"]
* @return string ex. "Q11023|Q1356"
*/
private fun joinQIDs(depictionsQIDs: List<String>?): String? {
return depictionsQIDs?.takeIf {
it.isNotEmpty()
}?.joinToString("|")
}
/**
* Returns nearest place matching the passed latitude and longitude
*
* @param decLatitude
* @param decLongitude
* @return
*/
fun checkNearbyPlaces(decLatitude: Double, decLongitude: Double): Place? {
return try {
val fromWikidataQuery = nearbyPlaces.getFromWikidataQuery(
LatLng(decLatitude, decLongitude, 0.0f),
Locale.getDefault().language,
NEARBY_RADIUS_IN_KILO_METERS,
null
)
fromWikidataQuery?.firstOrNull()
} catch (e: Exception) {
Timber.e("Error fetching nearby places: %s", e.message)
null
}
}
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int) {
uploadModel.useSimilarPictureCoordinates(
imageCoordinates,
uploadItemIndex
)
}
fun isWMLSupportedForThisPlace(): Boolean {
return uploadModel.items.firstOrNull()?.isWLMUpload == true
}
/**
* Provides selected existing categories
*
* @return selected existing categories
*/
fun getSelectedExistingCategories(): List<String> {
return categoriesModel.getSelectedExistingCategories()
}
/**
* Initialize existing categories
*
* @param selectedExistingCategories existing categories
*/
fun setSelectedExistingCategories(selectedExistingCategories: List<String>) {
categoriesModel.setSelectedExistingCategories(
selectedExistingCategories.toMutableList()
)
}
/**
* Takes category names and Gets CategoryItem from the server
*
* @param categories names of Category
* @return Observable<List<CategoryItem>>
*/
fun getCategories(categories: List<String>): Observable<List<CategoryItem>> {
return categoriesModel.getCategoriesByName(categories)
?.map { it.toList() } ?: Observable.empty()
}
}

View file

@ -1,334 +0,0 @@
package fr.free.nrw.commons.review;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
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.auth.AccountUtil;
import fr.free.nrw.commons.databinding.ActivityReviewBinding;
import fr.free.nrw.commons.delete.DeleteHelper;
import fr.free.nrw.commons.media.MediaDetailFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import java.util.Locale;
import javax.inject.Inject;
public class ReviewActivity extends BaseActivity {
private ActivityReviewBinding binding;
MediaDetailFragment mediaDetailFragment;
public ReviewPagerAdapter reviewPagerAdapter;
public ReviewController reviewController;
@Inject
ReviewHelper reviewHelper;
@Inject
DeleteHelper deleteHelper;
/**
* Represent fragment for ReviewImage
* Use to call some methods of ReviewImage fragment
*/
private ReviewImageFragment reviewImageFragment;
/**
* Flag to check whether there are any non-hidden categories in the File
*/
private boolean hasNonHiddenCategories = false;
final String SAVED_MEDIA = "saved_media";
private Media media;
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (media != null) {
outState.putParcelable(SAVED_MEDIA, media);
}
}
/**
* Consumers should be simply using this method to use this activity.
*
* @param context
* @param title Page title
*/
public static void startYourself(Context context, String title) {
Intent reviewActivity = new Intent(context, ReviewActivity.class);
reviewActivity.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
reviewActivity.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(reviewActivity);
}
private CompositeDisposable compositeDisposable = new CompositeDisposable();
public Media getMedia() {
return media;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityReviewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
reviewController = new ReviewController(deleteHelper, this);
reviewPagerAdapter = new ReviewPagerAdapter(getSupportFragmentManager());
binding.viewPagerReview.setAdapter(reviewPagerAdapter);
binding.pagerIndicatorReview.setViewPager(binding.viewPagerReview);
binding.pbReviewImage.setVisibility(View.VISIBLE);
Drawable d[]=binding.skipImage.getCompoundDrawablesRelative();
d[2].setColorFilter(getApplicationContext().getResources().getColor(R.color.button_blue), PorterDuff.Mode.SRC_IN);
if (savedInstanceState != null && savedInstanceState.getParcelable(SAVED_MEDIA) != null) {
updateImage(savedInstanceState.getParcelable(SAVED_MEDIA)); // Use existing media if we have one
setUpMediaDetailOnOrientation();
} else {
runRandomizer(); //Run randomizer whenever everything is ready so that a first random image will be added
}
binding.skipImage.setOnClickListener(view -> {
reviewImageFragment = getInstanceOfReviewImageFragment();
reviewImageFragment.disableButtons();
runRandomizer();
});
binding.reviewImageView.setOnClickListener(view ->setUpMediaDetailFragment());
binding.skipImage.setOnTouchListener((view, event) -> {
if (event.getAction() == MotionEvent.ACTION_UP && event.getRawX() >= (
binding.skipImage.getRight() - binding.skipImage
.getCompoundDrawables()[2].getBounds().width())) {
showSkipImageInfo();
return true;
}
return false;
});
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
@SuppressLint("CheckResult")
public boolean runRandomizer() {
hasNonHiddenCategories = false;
binding.pbReviewImage.setVisibility(View.VISIBLE);
binding.viewPagerReview.setCurrentItem(0);
// Finds non-hidden categories from Media instance
compositeDisposable.add(reviewHelper.getRandomMedia()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::checkWhetherFileIsUsedInWikis));
return true;
}
/**
* Check whether media is used or not in any Wiki Page
*/
@SuppressLint("CheckResult")
private void checkWhetherFileIsUsedInWikis(final Media media) {
compositeDisposable.add(reviewHelper.checkFileUsage(media.getFilename())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
// result false indicates media is not used in any wiki
if (!result) {
// Finds non-hidden categories from Media instance
findNonHiddenCategories(media);
} else {
runRandomizer();
}
}));
}
/**
* Finds non-hidden categories and updates current image
*/
private void findNonHiddenCategories(Media media) {
for(String key : media.getCategoriesHiddenStatus().keySet()) {
Boolean value = media.getCategoriesHiddenStatus().get(key);
// If non-hidden category is found then set hasNonHiddenCategories to true
// so that category review cannot be skipped
if(!value) {
hasNonHiddenCategories = true;
break;
}
}
reviewImageFragment = getInstanceOfReviewImageFragment();
reviewImageFragment.disableButtons();
updateImage(media);
}
@SuppressLint("CheckResult")
private void updateImage(Media media) {
reviewHelper.addViewedImagesToDB(media.getPageId());
this.media = media;
String fileName = media.getFilename();
if (fileName.length() == 0) {
ViewUtil.showShortSnackbar(binding.drawerLayout, R.string.error_review);
return;
}
//If The Media User and Current Session Username is same then Skip the Image
if (media.getUser() != null && media.getUser().equals(AccountUtil.getUserName(getApplicationContext()))) {
runRandomizer();
return;
}
binding.reviewImageView.setImageURI(media.getImageUrl());
reviewController.onImageRefreshed(media); //file name is updated
compositeDisposable.add(reviewHelper.getFirstRevisionOfFile(fileName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(revision -> {
reviewController.firstRevision = revision;
reviewPagerAdapter.updateFileInformation();
@SuppressLint({"StringFormatInvalid", "LocalSuppress"}) String caption = String.format(getString(R.string.review_is_uploaded_by), fileName, revision.getUser());
binding.tvImageCaption.setText(caption);
binding.pbReviewImage.setVisibility(View.GONE);
reviewImageFragment = getInstanceOfReviewImageFragment();
reviewImageFragment.enableButtons();
}));
binding.viewPagerReview.setCurrentItem(0);
}
public void swipeToNext() {
int nextPos = binding.viewPagerReview.getCurrentItem() + 1;
// If currently at category fragment, then check whether the media has any non-hidden category
if (nextPos <= 3) {
binding.viewPagerReview.setCurrentItem(nextPos);
if (nextPos == 2) {
// The media has no non-hidden category. Such media are already flagged by server-side bots, so no need to review manually.
if (!hasNonHiddenCategories) {
swipeToNext();
return;
}
}
} else {
runRandomizer();
}
}
@Override
public void onDestroy() {
super.onDestroy();
compositeDisposable.clear();
binding = null;
}
public void showSkipImageInfo(){
DialogUtil.showAlertDialog(ReviewActivity.this,
getString(R.string.skip_image).toUpperCase(Locale.ROOT),
getString(R.string.skip_image_explanation),
getString(android.R.string.ok),
"",
null,
null);
}
public void showReviewImageInfo() {
DialogUtil.showAlertDialog(ReviewActivity.this,
getString(R.string.title_activity_review),
getString(R.string.review_image_explanation),
getString(android.R.string.ok),
"",
null,
null);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.menu_review_activty, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_image_info:
showReviewImageInfo();
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* this function return the instance of reviewImageFragment
*/
public ReviewImageFragment getInstanceOfReviewImageFragment(){
int currentItemOfReviewPager = binding.viewPagerReview.getCurrentItem();
reviewImageFragment = (ReviewImageFragment) reviewPagerAdapter.instantiateItem(binding.viewPagerReview, currentItemOfReviewPager);
return reviewImageFragment;
}
/**
* set up the media detail fragment when click on the review image
*/
private void setUpMediaDetailFragment() {
if (binding.mediaDetailContainer.getVisibility() == View.GONE && media != null) {
binding.mediaDetailContainer.setVisibility(View.VISIBLE);
binding.reviewActivityContainer.setVisibility(View.INVISIBLE);
FragmentManager fragmentManager = getSupportFragmentManager();
mediaDetailFragment = new MediaDetailFragment();
Bundle bundle = new Bundle();
bundle.putParcelable("media", media);
mediaDetailFragment.setArguments(bundle);
fragmentManager.beginTransaction().add(R.id.mediaDetailContainer, mediaDetailFragment).
addToBackStack("MediaDetail").commit();
}
}
/**
* handle the back pressed event of this activity
* this function call every time when back button is pressed
*/
@Override
public void onBackPressed() {
if (binding.mediaDetailContainer.getVisibility() == View.VISIBLE) {
binding.mediaDetailContainer.setVisibility(View.GONE);
binding.reviewActivityContainer.setVisibility(View.VISIBLE);
}
super.onBackPressed();
}
/**
* set up media detail fragment after orientation change
*/
private void setUpMediaDetailOnOrientation() {
Fragment mediaDetailFragment = getSupportFragmentManager()
.findFragmentById(R.id.mediaDetailContainer);
if (mediaDetailFragment != null) {
binding.mediaDetailContainer.setVisibility(View.VISIBLE);
binding.reviewActivityContainer.setVisibility(View.INVISIBLE);
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediaDetailContainer, mediaDetailFragment).commit();
}
}
}

View file

@ -0,0 +1,336 @@
package fr.free.nrw.commons.review
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.PorterDuff
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.AccountUtil
import fr.free.nrw.commons.databinding.ActivityReviewBinding
import fr.free.nrw.commons.delete.DeleteHelper
import fr.free.nrw.commons.media.MediaDetailFragment
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.ViewUtil
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.util.Locale
import javax.inject.Inject
class ReviewActivity : BaseActivity() {
private lateinit var binding: ActivityReviewBinding
private var mediaDetailFragment: MediaDetailFragment? = null
lateinit var reviewPagerAdapter: ReviewPagerAdapter
lateinit var reviewController: ReviewController
@Inject
lateinit var reviewHelper: ReviewHelper
@Inject
lateinit var deleteHelper: DeleteHelper
/**
* Represent fragment for ReviewImage
* Use to call some methods of ReviewImage fragment
*/
private var reviewImageFragment: ReviewImageFragment? = null
private var hasNonHiddenCategories = false
var media: Media? = null
private val SAVED_MEDIA = "saved_media"
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
media?.let {
outState.putParcelable(SAVED_MEDIA, it)
}
}
/**
* Consumers should be simply using this method to use this activity.
*
* @param context
* @param title Page title
*/
companion object {
fun startYourself(context: Context, title: String) {
val reviewActivity = Intent(context, ReviewActivity::class.java)
reviewActivity.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
reviewActivity.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
context.startActivity(reviewActivity)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityReviewBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbarBinding?.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
reviewController = ReviewController(deleteHelper, this)
reviewPagerAdapter = ReviewPagerAdapter(supportFragmentManager)
binding.viewPagerReview.adapter = reviewPagerAdapter
binding.pagerIndicatorReview.setViewPager(binding.viewPagerReview)
binding.pbReviewImage.visibility = View.VISIBLE
binding.skipImage.compoundDrawablesRelative[2]?.setColorFilter(
resources.getColor(R.color.button_blue),
PorterDuff.Mode.SRC_IN
)
if (savedInstanceState?.getParcelable<Media>(SAVED_MEDIA) != null) {
updateImage(savedInstanceState.getParcelable(SAVED_MEDIA)!!)
setUpMediaDetailOnOrientation()
} else {
runRandomizer()
}
binding.skipImage.setOnClickListener {
reviewImageFragment = getInstanceOfReviewImageFragment()
reviewImageFragment?.disableButtons()
runRandomizer()
}
binding.reviewImageView.setOnClickListener {
setUpMediaDetailFragment()
}
binding.skipImage.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP &&
event.rawX >= (binding.skipImage.right - binding.skipImage.compoundDrawables[2].bounds.width())
) {
showSkipImageInfo()
true
} else {
false
}
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
@SuppressLint("CheckResult")
fun runRandomizer(): Boolean {
hasNonHiddenCategories = false
binding.pbReviewImage.visibility = View.VISIBLE
binding.viewPagerReview.currentItem = 0
compositeDisposable.add(
reviewHelper.getRandomMedia()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(::checkWhetherFileIsUsedInWikis)
)
return true
}
/**
* Check whether media is used or not in any Wiki Page
*/
@SuppressLint("CheckResult")
private fun checkWhetherFileIsUsedInWikis(media: Media) {
compositeDisposable.add(
reviewHelper.checkFileUsage(media.filename)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
if (!result) {
findNonHiddenCategories(media)
} else {
runRandomizer()
}
}
)
}
/**
* Finds non-hidden categories and updates current image
*/
private fun findNonHiddenCategories(media: Media) {
this.media = media
// If non-hidden category is found then set hasNonHiddenCategories to true
// so that category review cannot be skipped
hasNonHiddenCategories = media.categoriesHiddenStatus.values.any { !it }
reviewImageFragment = getInstanceOfReviewImageFragment()
reviewImageFragment?.disableButtons()
updateImage(media)
}
@SuppressLint("CheckResult")
private fun updateImage(media: Media) {
reviewHelper.addViewedImagesToDB(media.pageId)
this.media = media
val fileName = media.filename
if (fileName.isNullOrEmpty()) {
ViewUtil.showShortSnackbar(binding.drawerLayout, R.string.error_review)
return
}
//If The Media User and Current Session Username is same then Skip the Image
if (media.user == AccountUtil.getUserName(applicationContext)) {
runRandomizer()
return
}
binding.reviewImageView.setImageURI(media.imageUrl)
reviewController.onImageRefreshed(media) // filename is updated
compositeDisposable.add(
reviewHelper.getFirstRevisionOfFile(fileName)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { revision ->
reviewController.firstRevision = revision
reviewPagerAdapter.updateFileInformation()
val caption = getString(
R.string.review_is_uploaded_by,
fileName,
revision.user
)
binding.tvImageCaption.text = caption
binding.pbReviewImage.visibility = View.GONE
reviewImageFragment = getInstanceOfReviewImageFragment()
reviewImageFragment?.enableButtons()
}
)
binding.viewPagerReview.currentItem = 0
}
fun swipeToNext() {
val nextPos = binding.viewPagerReview.currentItem + 1
// If currently at category fragment, then check whether the media has any non-hidden category
if (nextPos <= 3) {
binding.viewPagerReview.currentItem = nextPos
if (nextPos == 2 && !hasNonHiddenCategories)
{
// The media has no non-hidden category. Such media are already flagged by server-side bots, so no need to review manually.
swipeToNext()
}
} else {
runRandomizer()
}
}
public override fun onDestroy() {
super.onDestroy()
compositeDisposable.clear()
}
fun showSkipImageInfo() {
DialogUtil.showAlertDialog(
this,
getString(R.string.skip_image).uppercase(Locale.ROOT),
getString(R.string.skip_image_explanation),
getString(android.R.string.ok),
null,
null,
null
)
}
fun showReviewImageInfo() {
DialogUtil.showAlertDialog(
this,
getString(R.string.title_activity_review),
getString(R.string.review_image_explanation),
getString(android.R.string.ok),
null,
null,
null
)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_review_activty, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_image_info -> {
showReviewImageInfo()
true
}
else -> super.onOptionsItemSelected(item)
}
}
/**
* this function return the instance of reviewImageFragment
*/
private fun getInstanceOfReviewImageFragment(): ReviewImageFragment? {
val currentItemOfReviewPager = binding.viewPagerReview.currentItem
return reviewPagerAdapter.instantiateItem(
binding.viewPagerReview,
currentItemOfReviewPager
) as? ReviewImageFragment
}
/**
* set up the media detail fragment when click on the review image
*/
private fun setUpMediaDetailFragment() {
if (binding.mediaDetailContainer.visibility == View.GONE && media != null) {
binding.mediaDetailContainer.visibility = View.VISIBLE
binding.reviewActivityContainer.visibility = View.INVISIBLE
val fragmentManager = supportFragmentManager
mediaDetailFragment = MediaDetailFragment().apply {
arguments = Bundle().apply {
putParcelable("media", media)
}
}
fragmentManager.beginTransaction()
.add(R.id.mediaDetailContainer, mediaDetailFragment!!)
.addToBackStack("MediaDetail")
.commit()
}
}
/**
* handle the back pressed event of this activity
* this function call every time when back button is pressed
*/
@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 (binding.mediaDetailContainer.visibility == View.VISIBLE) {
binding.mediaDetailContainer.visibility = View.GONE
binding.reviewActivityContainer.visibility = View.VISIBLE
}
super.onBackPressed()
}
/**
* set up media detail fragment after orientation change
*/
private fun setUpMediaDetailOnOrientation() {
val fragment = supportFragmentManager.findFragmentById(R.id.mediaDetailContainer)
fragment?.let {
binding.mediaDetailContainer.visibility = View.VISIBLE
binding.reviewActivityContainer.visibility = View.INVISIBLE
supportFragmentManager.beginTransaction()
.replace(R.id.mediaDetailContainer, it)
.commit()
}
}
}

View file

@ -1,220 +0,0 @@
package fr.free.nrw.commons.review;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage;
import java.util.ArrayList;
import java.util.concurrent.Callable;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.actions.ThanksClient;
import fr.free.nrw.commons.delete.DeleteHelper;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
@Singleton
public class ReviewController {
private static final int NOTIFICATION_SEND_THANK = 0x102;
private static final int NOTIFICATION_CHECK_CATEGORY = 0x101;
protected static ArrayList<String> categories;
@Inject
ThanksClient thanksClient;
@Inject
SessionManager sessionManager;
private final DeleteHelper deleteHelper;
@Nullable
MwQueryPage.Revision firstRevision; // TODO: maybe we can expand this class to include fileName
@Inject
@Named("commons-page-edit")
PageEditClient pageEditClient;
private NotificationManager notificationManager;
private NotificationCompat.Builder notificationBuilder;
private Media media;
ReviewController(DeleteHelper deleteHelper, Context context) {
this.deleteHelper = deleteHelper;
CommonsApplication.createNotificationChannel(context.getApplicationContext());
notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationBuilder = new NotificationCompat.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL);
}
void onImageRefreshed(Media media) {
this.media = media;
}
public Media getMedia() {
return media;
}
public enum DeleteReason {
SPAM,
COPYRIGHT_VIOLATION
}
void reportSpam(@NonNull Activity activity, ReviewCallback reviewCallback) {
Timber.d("Report spam for %s", media.getFilename());
deleteHelper.askReasonAndExecute(media,
activity,
activity.getResources().getString(R.string.review_spam_report_question),
DeleteReason.SPAM,
reviewCallback);
}
void reportPossibleCopyRightViolation(@NonNull Activity activity, ReviewCallback reviewCallback) {
Timber.d("Report spam for %s", media.getFilename());
deleteHelper.askReasonAndExecute(media,
activity,
activity.getResources().getString(R.string.review_c_violation_report_question),
DeleteReason.COPYRIGHT_VIOLATION,
reviewCallback);
}
@SuppressLint("CheckResult")
void reportWrongCategory(@NonNull Activity activity, ReviewCallback reviewCallback) {
Context context = activity.getApplicationContext();
ApplicationlessInjection
.getInstance(context)
.getCommonsApplicationComponent()
.inject(this);
ViewUtil.showShortToast(context, context.getString(R.string.check_category_toast, media.getDisplayTitle()));
publishProgress(context, 0);
String summary = context.getString(R.string.check_category_edit_summary);
Observable.defer((Callable<ObservableSource<Boolean>>) () ->
pageEditClient.appendEdit(media.getFilename(), "\n{{subst:chc}}\n", summary))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe((result) -> {
publishProgress(context, 2);
String message;
String title;
if (result) {
title = context.getString(R.string.check_category_success_title);
message = context.getString(R.string.check_category_success_message, media.getDisplayTitle());
reviewCallback.onSuccess();
} else {
title = context.getString(R.string.check_category_failure_title);
message = context.getString(R.string.check_category_failure_message, media.getDisplayTitle());
reviewCallback.onFailure();
}
showNotification(title, message);
}, Timber::e);
}
private void publishProgress(@NonNull Context context, int i) {
int[] messages = new int[]{R.string.getting_edit_token, R.string.check_category_adding_template};
String message = "";
if (0 < i && i < messages.length) {
message = context.getString(messages[i]);
}
notificationBuilder.setContentTitle(context.getString(R.string.check_category_notification_title, media.getDisplayTitle()))
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(message))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(messages.length, i, false)
.setOngoing(true);
notificationManager.notify(NOTIFICATION_CHECK_CATEGORY, notificationBuilder.build());
}
@SuppressLint({"CheckResult", "StringFormatInvalid"})
void sendThanks(@NonNull Activity activity) {
Context context = activity.getApplicationContext();
ApplicationlessInjection
.getInstance(context)
.getCommonsApplicationComponent()
.inject(this);
ViewUtil.showShortToast(context, context.getString(R.string.send_thank_toast, media.getDisplayTitle()));
if (firstRevision == null) {
return;
}
Observable.defer((Callable<ObservableSource<Boolean>>) () -> thanksClient.thank(firstRevision.getRevisionId()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
displayThanksToast(context, result);
}, throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
activity,
activity.getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
activity, logoutListener);
} else {
Timber.e(throwable);
}
});
}
@SuppressLint("StringFormatInvalid")
private void displayThanksToast(final Context context, final boolean result){
final String message;
final String title;
if (result) {
title = context.getString(R.string.send_thank_success_title);
message = context.getString(R.string.send_thank_success_message, media.getDisplayTitle());
} else {
title = context.getString(R.string.send_thank_failure_title);
message = context.getString(R.string.send_thank_failure_message, media.getDisplayTitle());
}
ViewUtil.showShortToast(context,message);
}
private void showNotification(String title, String message) {
notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentTitle(title)
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(message))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(0, 0, false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_HIGH);
notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build());
}
public interface ReviewCallback {
void onSuccess();
void onFailure();
void onTokenException(Exception e);
void disableButtons();
void enableButtons();
}
}

View file

@ -0,0 +1,231 @@
package fr.free.nrw.commons.review
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage
import java.util.ArrayList
import java.util.concurrent.Callable
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.actions.PageEditClient
import fr.free.nrw.commons.actions.ThanksClient
import fr.free.nrw.commons.delete.DeleteHelper
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.utils.ViewUtil
import io.reactivex.Observable
import io.reactivex.ObservableSource
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
@Singleton
class ReviewController @Inject constructor(
private val deleteHelper: DeleteHelper,
context: Context
) {
companion object {
private const val NOTIFICATION_SEND_THANK = 0x102
private const val NOTIFICATION_CHECK_CATEGORY = 0x101
protected var categories: ArrayList<String> = ArrayList()
}
@Inject
lateinit var thanksClient: ThanksClient
@Inject
lateinit var sessionManager: SessionManager
@Inject
@field: Named("commons-page-edit")
lateinit var pageEditClient: PageEditClient
var firstRevision: MwQueryPage.Revision? = null // TODO: maybe we can expand this class to include fileName
private val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notificationBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(context, CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)
var media: Media? = null
init {
CommonsApplication.createNotificationChannel(context.applicationContext)
}
fun onImageRefreshed(media: Media) {
this.media = media
}
enum class DeleteReason {
SPAM,
COPYRIGHT_VIOLATION
}
fun reportSpam(activity: Activity, reviewCallback: ReviewCallback) {
Timber.d("Report spam for %s", media?.filename)
deleteHelper.askReasonAndExecute(
media,
activity,
activity.resources.getString(R.string.review_spam_report_question),
DeleteReason.SPAM,
reviewCallback
)
}
fun reportPossibleCopyRightViolation(activity: Activity, reviewCallback: ReviewCallback) {
Timber.d("Report copyright violation for %s", media?.filename)
deleteHelper.askReasonAndExecute(
media,
activity,
activity.resources.getString(R.string.review_c_violation_report_question),
DeleteReason.COPYRIGHT_VIOLATION,
reviewCallback
)
}
@SuppressLint("CheckResult")
fun reportWrongCategory(activity: Activity, reviewCallback: ReviewCallback) {
val context = activity.applicationContext
ApplicationlessInjection
.getInstance(context)
.commonsApplicationComponent
.inject(this)
ViewUtil.showShortToast(
context,
context.getString(R.string.check_category_toast, media?.displayTitle)
)
publishProgress(context, 0)
val summary = context.getString(R.string.check_category_edit_summary)
Observable.defer {
pageEditClient.appendEdit(media?.filename ?: "", "\n{{subst:chc}}\n", summary)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
publishProgress(context, 2)
val (title, message) = if (result) {
reviewCallback.onSuccess()
context.getString(R.string.check_category_success_title) to
context.getString(R.string.check_category_success_message, media?.displayTitle)
} else {
reviewCallback.onFailure()
context.getString(R.string.check_category_failure_title) to
context.getString(R.string.check_category_failure_message, media?.displayTitle)
}
showNotification(title, message)
}, Timber::e)
}
private fun publishProgress(context: Context, progress: Int) {
val messages = arrayOf(
R.string.getting_edit_token,
R.string.check_category_adding_template
)
val message = if (progress in 1 until messages.size) {
context.getString(messages[progress])
} else ""
notificationBuilder.setContentTitle(
context.getString(
R.string.check_category_notification_title,
media?.displayTitle
)
)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(messages.size, progress, false)
.setOngoing(true)
notificationManager.notify(NOTIFICATION_CHECK_CATEGORY, notificationBuilder.build())
}
@SuppressLint("CheckResult")
fun sendThanks(activity: Activity) {
val context = activity.applicationContext
ApplicationlessInjection
.getInstance(context)
.commonsApplicationComponent
.inject(this)
ViewUtil.showShortToast(
context,
context.getString(R.string.send_thank_toast, media?.displayTitle)
)
if (firstRevision == null) return
Observable.defer {
thanksClient.thank(firstRevision!!.revisionId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
displayThanksToast(context, result)
}, { throwable ->
if (throwable is InvalidLoginTokenException) {
val username = sessionManager.userName
val logoutListener = CommonsApplication.BaseLogoutListener(
activity,
activity.getString(R.string.invalid_login_message),
username
)
CommonsApplication.instance.clearApplicationData(activity, logoutListener)
} else {
Timber.e(throwable)
}
})
}
@SuppressLint("StringFormatInvalid")
private fun displayThanksToast(context: Context, result: Boolean) {
val (title, message) = if (result) {
context.getString(R.string.send_thank_success_title) to
context.getString(R.string.send_thank_success_message, media?.displayTitle)
} else {
context.getString(R.string.send_thank_failure_title) to
context.getString(R.string.send_thank_failure_message, media?.displayTitle)
}
ViewUtil.showShortToast(context, message)
}
private fun showNotification(title: String, message: String) {
notificationBuilder.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentTitle(title)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setSmallIcon(R.drawable.ic_launcher)
.setProgress(0, 0, false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_HIGH)
notificationManager.notify(NOTIFICATION_SEND_THANK, notificationBuilder.build())
}
interface ReviewCallback {
fun onSuccess()
fun onFailure()
fun onTokenException(e: Exception)
fun disableButtons()
fun enableButtons()
}
}

View file

@ -1,15 +1,15 @@
package fr.free.nrw.commons.review;
package fr.free.nrw.commons.review
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
/**
* Dao interface for reviewed images database
*/
@Dao
public interface ReviewDao {
interface ReviewDao {
/**
* Inserts reviewed/skipped image identifier into the database
@ -17,7 +17,7 @@ public interface ReviewDao {
* @param reviewEntity
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
void insert(ReviewEntity reviewEntity);
fun insert(reviewEntity: ReviewEntity)
/**
* Checks if the image has already been reviewed/skipped by the user
@ -26,7 +26,6 @@ public interface ReviewDao {
* @param imageId
* @return
*/
@Query( "SELECT EXISTS (SELECT * from `reviewed-images` where imageId = (:imageId))")
Boolean isReviewedAlready(String imageId);
@Query("SELECT EXISTS (SELECT * from `reviewed-images` where imageId = (:imageId))")
fun isReviewedAlready(imageId: String): Boolean
}

View file

@ -1,19 +0,0 @@
package fr.free.nrw.commons.review;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
/**
* Entity to store reviewed/skipped images identifier
*/
@Entity(tableName = "reviewed-images")
public class ReviewEntity {
@PrimaryKey
@NonNull
String imageId;
public ReviewEntity(String imageId) {
this.imageId = imageId;
}
}

View file

@ -0,0 +1,13 @@
package fr.free.nrw.commons.review
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Entity to store reviewed/skipped images identifier
*/
@Entity(tableName = "reviewed-images")
data class ReviewEntity(
@PrimaryKey
val imageId: String
)

View file

@ -77,7 +77,7 @@ class ReviewHelper
* @param image
* @return
*/
fun getReviewStatus(image: String?): Boolean = dao?.isReviewedAlready(image) ?: false
fun getReviewStatus(image: String?): Boolean = image?.let { dao?.isReviewedAlready(it) } ?: false
/**
* Gets the first revision of the file from filename
@ -132,7 +132,7 @@ class ReviewHelper
*/
fun addViewedImagesToDB(imageId: String?) {
Completable
.fromAction { dao!!.insert(ReviewEntity(imageId)) }
.fromAction { imageId?.let { ReviewEntity(it) }?.let { dao!!.insert(it) } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(

View file

@ -1,262 +0,0 @@
package fr.free.nrw.commons.review;
import android.graphics.Color;
import android.os.Bundle;
import android.text.Html;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.databinding.FragmentReviewImageBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
public class ReviewImageFragment extends CommonsDaggerSupportFragment {
static final int CATEGORY = 2;
private static final int SPAM = 0;
private static final int COPYRIGHT = 1;
private static final int THANKS = 3;
private int position;
private FragmentReviewImageBinding binding;
@Inject
SessionManager sessionManager;
// Constant variable used to store user's key name for onSaveInstanceState method
private final String SAVED_USER = "saved_user";
// Variable that stores the value of user
private String user;
public void update(final int position) {
this.position = position;
}
private String updateCategoriesQuestion() {
final Media media = getReviewActivity().getMedia();
if (media != null && media.getCategoriesHiddenStatus() != null && isAdded()) {
// Filter category name attribute from all categories
final List<String> categories = new ArrayList<>();
for(final String key : media.getCategoriesHiddenStatus().keySet()) {
String value = String.valueOf(key);
// Each category returned has a format like "Category:<some-category-name>"
// so remove the prefix "Category:"
final int index = key.indexOf("Category:");
if(index == 0) {
value = key.substring(9);
}
categories.add(value);
}
String catString = TextUtils.join(", ", categories);
if (catString != null && !catString.equals("") && binding.tvReviewQuestionContext != null) {
catString = "<b>" + catString + "</b>";
final String stringToConvertHtml = String.format(getResources().getString(R.string.review_category_explanation), catString);
return Html.fromHtml(stringToConvertHtml).toString();
}
}
return getResources().getString(R.string.review_no_category);
}
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
position = getArguments().getInt("position");
binding = FragmentReviewImageBinding.inflate(inflater, container, false);
final String question;
String explanation=null;
String yesButtonText;
final String noButtonText;
binding.buttonYes.setOnClickListener(view -> onYesButtonClicked());
switch (position) {
case SPAM:
question = getString(R.string.review_spam);
explanation = getString(R.string.review_spam_explanation);
yesButtonText = getString(R.string.yes);
noButtonText = getString(R.string.no);
binding.buttonNo.setOnClickListener(view -> getReviewActivity()
.reviewController.reportSpam(requireActivity(), getReviewCallback()));
break;
case COPYRIGHT:
enableButtons();
question = getString(R.string.review_copyright);
explanation = getString(R.string.review_copyright_explanation);
yesButtonText = getString(R.string.yes);
noButtonText = getString(R.string.no);
binding.buttonNo.setOnClickListener(view -> getReviewActivity()
.reviewController
.reportPossibleCopyRightViolation(requireActivity(), getReviewCallback()));
break;
case CATEGORY:
enableButtons();
question = getString(R.string.review_category);
explanation = updateCategoriesQuestion();
yesButtonText = getString(R.string.yes);
noButtonText = getString(R.string.no);
binding.buttonNo.setOnClickListener(view -> {
getReviewActivity()
.reviewController
.reportWrongCategory(requireActivity(), getReviewCallback());
getReviewActivity().swipeToNext();
});
break;
case THANKS:
enableButtons();
question = getString(R.string.review_thanks);
if (getReviewActivity().reviewController.firstRevision != null) {
user = getReviewActivity().reviewController.firstRevision.getUser();
} else {
if(savedInstanceState != null) {
user = savedInstanceState.getString(SAVED_USER);
}
}
//if the user is null because of whatsoever reason, review will not be sent anyways
if (!TextUtils.isEmpty(user)) {
explanation = getString(R.string.review_thanks_explanation, user);
}
// Note that the yes and no buttons are swapped in this section
yesButtonText = getString(R.string.review_thanks_yes_button_text);
noButtonText = getString(R.string.review_thanks_no_button_text);
binding.buttonYes.setTextColor(Color.parseColor("#116aaa"));
binding.buttonNo.setTextColor(Color.parseColor("#228b22"));
binding.buttonNo.setOnClickListener(view -> {
getReviewActivity().reviewController.sendThanks(getReviewActivity());
getReviewActivity().swipeToNext();
});
break;
default:
enableButtons();
question = "How did we get here?";
explanation = "No idea.";
yesButtonText = "yes";
noButtonText = "no";
}
binding.tvReviewQuestion.setText(question);
binding.tvReviewQuestionContext.setText(explanation);
binding.buttonYes.setText(yesButtonText);
binding.buttonNo.setText(noButtonText);
return binding.getRoot();
}
/**
* This method will be called when configuration changes happen
*
* @param outState
*/
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
//Save user name when configuration changes happen
outState.putString(SAVED_USER, user);
}
private ReviewController.ReviewCallback getReviewCallback() {
return new ReviewController
.ReviewCallback() {
@Override
public void onSuccess() {
getReviewActivity().runRandomizer();
}
@Override
public void onFailure() {
//do nothing
}
@Override
public void onTokenException(final Exception e) {
if (e instanceof InvalidLoginTokenException){
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
getActivity(),
requireActivity().getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
requireActivity(), logoutListener);
}
}
/**
* This function is called when an image is being loaded
* to disable the review buttons
*/
@Override
public void disableButtons() {
ReviewImageFragment.this.disableButtons();
}
/**
* This function is called when an image has
* been loaded to enable the review buttons.
*/
@Override
public void enableButtons() {
ReviewImageFragment.this.enableButtons();
}
};
}
/**
* This function is called when an image has
* been loaded to enable the review buttons.
*/
public void enableButtons() {
binding.buttonYes.setEnabled(true);
binding.buttonYes.setAlpha(1);
binding.buttonNo.setEnabled(true);
binding.buttonNo.setAlpha(1);
}
/**
* This function is called when an image is being loaded
* to disable the review buttons
*/
public void disableButtons() {
binding.buttonYes.setEnabled(false);
binding.buttonYes.setAlpha(0.5f);
binding.buttonNo.setEnabled(false);
binding.buttonNo.setAlpha(0.5f);
}
void onYesButtonClicked() {
getReviewActivity().swipeToNext();
}
private ReviewActivity getReviewActivity() {
return (ReviewActivity) requireActivity();
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,251 @@
package fr.free.nrw.commons.review
import android.graphics.Color
import android.os.Bundle
import android.text.Html
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.databinding.FragmentReviewImageBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import java.util.ArrayList
import javax.inject.Inject
class ReviewImageFragment : CommonsDaggerSupportFragment() {
companion object {
const val CATEGORY = 2
private const val SPAM = 0
private const val COPYRIGHT = 1
private const val THANKS = 3
}
private var position: Int = 0
private var binding: FragmentReviewImageBinding? = null
@Inject
lateinit var sessionManager: SessionManager
// Constant variable used to store user's key name for onSaveInstanceState method
private val SAVED_USER = "saved_user"
// Variable that stores the value of user
private var user: String? = null
fun update(position: Int) {
this.position = position
}
private fun updateCategoriesQuestion(): String {
val media = reviewActivity.media
if (media?.categoriesHiddenStatus != null && isAdded) {
// Filter category name attribute from all categories
val categories = media.categoriesHiddenStatus.keys.map { key ->
var value = key
// Each category returned has a format like "Category:<some-category-name>"
// so remove the prefix "Category:"
if (key.startsWith("Category:")) {
value = key.substring(9)
}
value
}
val catString = categories.joinToString(", ")
if (catString.isNotEmpty() && binding?.tvReviewQuestionContext != null) {
val formattedCatString = "<b>$catString</b>"
val stringToConvertHtml = getString(
R.string.review_category_explanation,
formattedCatString
)
return Html.fromHtml(stringToConvertHtml).toString()
}
}
return getString(R.string.review_no_category)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
position = requireArguments().getInt("position")
binding = FragmentReviewImageBinding.inflate(inflater, container, false)
val question: String
var explanation: String? = null
val yesButtonText: String
val noButtonText: String
binding?.buttonYes?.setOnClickListener { onYesButtonClicked() }
when (position) {
SPAM -> {
question = getString(R.string.review_spam)
explanation = getString(R.string.review_spam_explanation)
yesButtonText = getString(R.string.yes)
noButtonText = getString(R.string.no)
binding?.buttonNo?.setOnClickListener {
reviewActivity.reviewController.reportSpam(requireActivity(), reviewCallback)
}
}
COPYRIGHT -> {
enableButtons()
question = getString(R.string.review_copyright)
explanation = getString(R.string.review_copyright_explanation)
yesButtonText = getString(R.string.yes)
noButtonText = getString(R.string.no)
binding?.buttonNo?.setOnClickListener {
reviewActivity.reviewController.reportPossibleCopyRightViolation(
requireActivity(),
reviewCallback
)
}
}
CATEGORY -> {
enableButtons()
question = getString(R.string.review_category)
explanation = updateCategoriesQuestion()
yesButtonText = getString(R.string.yes)
noButtonText = getString(R.string.no)
binding?.buttonNo?.setOnClickListener {
reviewActivity.reviewController.reportWrongCategory(
requireActivity(),
reviewCallback
)
reviewActivity.swipeToNext()
}
}
THANKS -> {
enableButtons()
question = getString(R.string.review_thanks)
user = reviewActivity.reviewController.firstRevision?.user
?: savedInstanceState?.getString(SAVED_USER)
//if the user is null because of whatsoever reason, review will not be sent anyways
if (!user.isNullOrEmpty()) {
explanation = getString(R.string.review_thanks_explanation, user)
}
// Note that the yes and no buttons are swapped in this section
yesButtonText = getString(R.string.review_thanks_yes_button_text)
noButtonText = getString(R.string.review_thanks_no_button_text)
binding?.buttonYes?.setTextColor(Color.parseColor("#116aaa"))
binding?.buttonNo?.setTextColor(Color.parseColor("#228b22"))
binding?.buttonNo?.setOnClickListener {
reviewActivity.reviewController.sendThanks(requireActivity())
reviewActivity.swipeToNext()
}
}
else -> {
enableButtons()
question = "How did we get here?"
explanation = "No idea."
yesButtonText = "yes"
noButtonText = "no"
}
}
binding?.apply {
tvReviewQuestion.text = question
tvReviewQuestionContext.text = explanation
buttonYes.text = yesButtonText
buttonNo.text = noButtonText
}
return binding?.root
}
/**
* This method will be called when configuration changes happen
*
* @param outState
*/
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
//Save user name when configuration changes happen
outState.putString(SAVED_USER, user)
}
private val reviewCallback: ReviewController.ReviewCallback
get() = object : ReviewController.ReviewCallback {
override fun onSuccess() {
reviewActivity.runRandomizer()
}
override fun onFailure() {
//do nothing
}
override fun onTokenException(e: Exception) {
if (e is InvalidLoginTokenException) {
val username = sessionManager.userName
val logoutListener = activity?.let {
CommonsApplication.BaseLogoutListener(
it,
getString(R.string.invalid_login_message),
username
)
}
if (logoutListener != null) {
CommonsApplication.instance.clearApplicationData(
requireActivity(), logoutListener
)
}
}
}
override fun disableButtons() {
this@ReviewImageFragment.disableButtons()
}
override fun enableButtons() {
this@ReviewImageFragment.enableButtons()
}
}
/**
* This function is called when an image has
* been loaded to enable the review buttons.
*/
fun enableButtons() {
binding?.apply {
buttonYes.isEnabled = true
buttonYes.alpha = 1f
buttonNo.isEnabled = true
buttonNo.alpha = 1f
}
}
/**
* This function is called when an image is being loaded
* to disable the review buttons
*/
fun disableButtons() {
binding?.apply {
buttonYes.isEnabled = false
buttonYes.alpha = 0.5f
buttonNo.isEnabled = false
buttonNo.alpha = 0.5f
}
}
fun onYesButtonClicked() {
reviewActivity.swipeToNext()
}
private val reviewActivity: ReviewActivity
get() = requireActivity() as ReviewActivity
override fun onDestroy() {
super.onDestroy()
binding = null
}
}

View file

@ -1,53 +0,0 @@
package fr.free.nrw.commons.review;
import android.os.Bundle;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
public class ReviewPagerAdapter extends FragmentStatePagerAdapter {
private ReviewImageFragment[] reviewImageFragments;
/**
* this function return the instance of ReviewviewPage current item
*/
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
return super.instantiateItem(container, position);
}
ReviewPagerAdapter(FragmentManager fm) {
super(fm);
reviewImageFragments = new ReviewImageFragment[]{
new ReviewImageFragment(),
new ReviewImageFragment(),
new ReviewImageFragment(),
new ReviewImageFragment()
};
}
@Override
public int getCount() {
return reviewImageFragments.length;
}
void updateFileInformation() {
for (int i = 0; i < getCount(); i++) {
ReviewImageFragment fragment = reviewImageFragments[i];
fragment.update(i);
}
}
@Override
public Fragment getItem(int position) {
Bundle bundle = new Bundle();
bundle.putInt("position", position);
reviewImageFragments[position].setArguments(bundle);
return reviewImageFragments[position];
}
}

View file

@ -0,0 +1,37 @@
package fr.free.nrw.commons.review
import android.os.Bundle
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
class ReviewPagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) {
private val reviewImageFragments: Array<ReviewImageFragment> = arrayOf(
ReviewImageFragment(),
ReviewImageFragment(),
ReviewImageFragment(),
ReviewImageFragment()
)
override fun getCount(): Int {
return reviewImageFragments.size
}
fun updateFileInformation() {
for (i in 0 until count) {
val fragment = reviewImageFragments[i]
fragment.update(i)
}
}
override fun getItem(position: Int): Fragment {
val bundle = Bundle().apply {
putInt("position", position)
}
reviewImageFragments[position].arguments = bundle
return reviewImageFragments[position]
}
}

View file

@ -1,30 +0,0 @@
package fr.free.nrw.commons.review;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import androidx.viewpager.widget.ViewPager;
public class ReviewViewPager extends ViewPager {
public ReviewViewPager(Context context) {
super(context);
}
public ReviewViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// Never allow swiping to switch between pages
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Never allow swiping to switch between pages
return false;
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.review
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
class ReviewViewPager @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ViewPager(context, attrs) {
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
// Never allow swiping to switch between pages
return false
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
// Never allow swiping to switch between pages
return false
}
}

View file

@ -1,21 +0,0 @@
package fr.free.nrw.commons.settings;
public class Prefs {
public static String GLOBAL_PREFS = "fr.free.nrw.commons.preferences";
public static String TRACKING_ENABLED = "eventLogging";
public static final String DEFAULT_LICENSE = "defaultLicense";
public static final String UPLOADS_SHOWING = "uploadsshowing";
public static final String MANAGED_EXIF_TAGS = "managed_exif_tags";
public static final String DESCRIPTION_LANGUAGE = "languageDescription";
public static final String APP_UI_LANGUAGE = "appUiLanguage";
public static final String KEY_THEME_VALUE = "appThemePref";
public static class Licenses {
public static final String CC_BY_SA_3 = "CC BY-SA 3.0";
public static final String CC_BY_3 = "CC BY 3.0";
public static final String CC_BY_SA_4 = "CC BY-SA 4.0";
public static final String CC_BY_4 = "CC BY 4.0";
public static final String CC0 = "CC0";
}
}

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.settings
object Prefs {
const val GLOBAL_PREFS = "fr.free.nrw.commons.preferences"
const val TRACKING_ENABLED = "eventLogging"
const val DEFAULT_LICENSE = "defaultLicense"
const val UPLOADS_SHOWING = "uploadsShowing"
const val MANAGED_EXIF_TAGS = "managed_exif_tags"
const val DESCRIPTION_LANGUAGE = "languageDescription"
const val APP_UI_LANGUAGE = "appUiLanguage"
const val KEY_THEME_VALUE = "appThemePref"
object Licenses {
const val CC_BY_SA_3 = "CC BY-SA 3.0"
const val CC_BY_3 = "CC BY 3.0"
const val CC_BY_SA_4 = "CC BY-SA 4.0"
const val CC_BY_4 = "CC BY 4.0"
const val CC0 = "CC0"
}
}

View file

@ -1,69 +0,0 @@
package fr.free.nrw.commons.settings;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import androidx.appcompat.app.AppCompatDelegate;
import fr.free.nrw.commons.databinding.ActivitySettingsBinding;
import fr.free.nrw.commons.theme.BaseActivity;
/**
* allows the user to change the settings
*/
public class SettingsActivity extends BaseActivity {
private ActivitySettingsBinding binding;
// private AppCompatDelegate settingsDelegate;
/**
* to be called when the activity starts
* @param savedInstanceState the previously saved state
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivitySettingsBinding.inflate(getLayoutInflater());
final View view = binding.getRoot();
setContentView(view);
setSupportActionBar(binding.toolbarBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
// Get an action bar
/**
* takes care of actions taken after the creation has happened
* @param savedInstanceState the saved state
*/
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
// if (settingsDelegate == null) {
// settingsDelegate = AppCompatDelegate.create(this, null);
// }
// settingsDelegate.onPostCreate(savedInstanceState);
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
/**
* Handle action-bar clicks
* @param item the selected item
* @return true on success, false on failure
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}

View file

@ -0,0 +1,63 @@
package fr.free.nrw.commons.settings
import android.os.Bundle
import android.view.MenuItem
import fr.free.nrw.commons.databinding.ActivitySettingsBinding
import fr.free.nrw.commons.theme.BaseActivity
/**
* allows the user to change the settings
*/
class SettingsActivity : BaseActivity() {
private lateinit var binding: ActivitySettingsBinding
// private var settingsDelegate: AppCompatDelegate? = null
/**
* to be called when the activity starts
* @param savedInstanceState the previously saved state
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
setSupportActionBar(binding.toolbarBinding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
// Get an action bar
/**
* takes care of actions taken after the creation has happened
* @param savedInstanceState the saved state
*/
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// if (settingsDelegate == null) {
// settingsDelegate = AppCompatDelegate.create(this, null)
// }
// settingsDelegate?.onPostCreate(savedInstanceState)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
/**
* Handle action-bar clicks
* @param item the selected item
* @return true on success, false on failure
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
}

View file

@ -1,561 +0,0 @@
package fr.free.nrw.commons.settings;
import static android.content.Context.MODE_PRIVATE;
import android.Manifest.permission;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.preference.ListPreference;
import androidx.preference.MultiSelectListPreference;
import androidx.preference.Preference;
import androidx.preference.Preference.OnPreferenceClickListener;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.PreferenceGroupAdapter;
import androidx.preference.PreferenceScreen;
import androidx.preference.PreferenceViewHolder;
import androidx.recyclerview.widget.RecyclerView.Adapter;
import com.karumi.dexter.Dexter;
import com.karumi.dexter.MultiplePermissionsReport;
import com.karumi.dexter.PermissionToken;
import com.karumi.dexter.listener.PermissionRequest;
import com.karumi.dexter.listener.multi.MultiplePermissionsListener;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.campaigns.CampaignView;
import fr.free.nrw.commons.contributions.ContributionController;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.logging.CommonsLogSender;
import fr.free.nrw.commons.recentlanguages.Language;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao;
import fr.free.nrw.commons.upload.LanguagesAdapter;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
public class SettingsFragment extends PreferenceFragmentCompat {
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
@Inject
CommonsLogSender commonsLogSender;
@Inject
RecentLanguagesDao recentLanguagesDao;
@Inject
ContributionController contributionController;
@Inject
LocationServiceManager locationManager;
private ListPreference themeListPreference;
private Preference descriptionLanguageListPreference;
private Preference appUiLanguageListPreference;
private String keyLanguageListPreference;
private TextView recentLanguagesTextView;
private View separator;
private ListView languageHistoryListView;
private static final String GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content";
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (!areAllGranted && shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
}
}
});
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
ApplicationlessInjection
.getInstance(getActivity().getApplicationContext())
.getCommonsApplicationComponent()
.inject(this);
// Set the preferences from an XML resource
setPreferencesFromResource(R.xml.preferences, rootKey);
themeListPreference = findPreference(Prefs.KEY_THEME_VALUE);
prepareTheme();
MultiSelectListPreference multiSelectListPref = findPreference(Prefs.MANAGED_EXIF_TAGS);
if (multiSelectListPref != null) {
multiSelectListPref.setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof HashSet && !((HashSet) newValue).contains(getString(R.string.exif_tag_location))) {
defaultKvStore.putBoolean("has_user_manually_removed_location", true);
}
return true;
});
}
Preference inAppCameraLocationPref = findPreference("inAppCameraLocationPref");
inAppCameraLocationPref.setOnPreferenceChangeListener(
(preference, newValue) -> {
boolean isInAppCameraLocationTurnedOn = (boolean) newValue;
if (isInAppCameraLocationTurnedOn) {
createDialogsAndHandleLocationPermissions(getActivity());
}
return true;
}
);
// Gets current language code from shared preferences
String languageCode;
appUiLanguageListPreference = findPreference("appUiDefaultLanguagePref");
assert appUiLanguageListPreference != null;
keyLanguageListPreference = appUiLanguageListPreference.getKey();
languageCode = getCurrentLanguageCode(keyLanguageListPreference);
assert languageCode != null;
if (languageCode.equals("")) {
// If current language code is empty, means none selected by user yet so use phone local
appUiLanguageListPreference.setSummary(Locale.getDefault().getDisplayLanguage());
} else {
// If any language is selected by user previously, use it
Locale defLocale = createLocale(languageCode);
appUiLanguageListPreference.setSummary((defLocale).getDisplayLanguage(defLocale));
}
appUiLanguageListPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() {
@Override
public boolean onPreferenceClick(Preference preference) {
prepareAppLanguages(appUiLanguageListPreference.getKey());
return true;
}
});
descriptionLanguageListPreference = findPreference("descriptionDefaultLanguagePref");
assert descriptionLanguageListPreference != null;
keyLanguageListPreference = descriptionLanguageListPreference.getKey();
languageCode = getCurrentLanguageCode(keyLanguageListPreference);
assert languageCode != null;
if (languageCode.equals("")) {
// If current language code is empty, means none selected by user yet so use phone local
descriptionLanguageListPreference.setSummary(Locale.getDefault().getDisplayLanguage());
} else {
// If any language is selected by user previously, use it
Locale defLocale = createLocale(languageCode);
descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale));
}
descriptionLanguageListPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() {
@Override
public boolean onPreferenceClick(Preference preference) {
prepareAppLanguages(descriptionLanguageListPreference.getKey());
return true;
}
});
Preference betaTesterPreference = findPreference("becomeBetaTester");
betaTesterPreference.setOnPreferenceClickListener(preference -> {
Utils.handleWebUrl(getActivity(), Uri.parse(getResources().getString(R.string.beta_opt_in_link)));
return true;
});
Preference sendLogsPreference = findPreference("sendLogFile");
sendLogsPreference.setOnPreferenceClickListener(preference -> {
checkPermissionsAndSendLogs();
return true;
});
Preference documentBasedPickerPreference = findPreference("openDocumentPhotoPickerPref");
documentBasedPickerPreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
boolean isGetContentPickerTurnedOn = !(boolean) newValue;
if (isGetContentPickerTurnedOn) {
showLocationLossWarning();
}
return true;
}
);
// Disable some settings when not logged in.
if (defaultKvStore.getBoolean("login_skipped", false)) {
findPreference("useExternalStorage").setEnabled(false);
findPreference("useAuthorName").setEnabled(false);
findPreference("displayNearbyCardView").setEnabled(false);
findPreference("descriptionDefaultLanguagePref").setEnabled(false);
findPreference("displayLocationPermissionForCardView").setEnabled(false);
findPreference(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE).setEnabled(false);
findPreference("managed_exif_tags").setEnabled(false);
findPreference("openDocumentPhotoPickerPref").setEnabled(false);
findPreference("inAppCameraLocationPref").setEnabled(false);
}
}
/**
* Asks users to provide location access
*
* @param activity
*/
private void createDialogsAndHandleLocationPermissions(Activity activity) {
inAppCameraLocationPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION});
}
/**
* On some devices, the new Photo Picker with GET_CONTENT takeover
* redacts location tags from EXIF metadata
*
* Show warning to the user when ACTION_GET_CONTENT intent is enabled
*/
private void showLocationLossWarning() {
DialogUtil.showAlertDialog(
getActivity(),
null,
getString(R.string.location_loss_warning),
getString(R.string.ok),
getString(R.string.read_help_link),
() -> {},
() -> Utils.handleWebUrl(requireContext(), Uri.parse(GET_CONTENT_PICKER_HELP_URL)),
null,
true
);
}
@Override
protected Adapter onCreateAdapter(final PreferenceScreen preferenceScreen) {
return new PreferenceGroupAdapter(preferenceScreen) {
@Override
public void onBindViewHolder(PreferenceViewHolder holder, int position) {
super.onBindViewHolder(holder, position);
Preference preference = getItem(position);
View iconFrame = holder.itemView.findViewById(R.id.icon_frame);
if (iconFrame != null) {
iconFrame.setVisibility(View.GONE);
}
}
};
}
/**
* Sets the theme pref
*/
private void prepareTheme() {
themeListPreference.setOnPreferenceChangeListener((preference, newValue) -> {
getActivity().recreate();
return true;
});
}
/**
* Prepare and Show language selection dialog box
* Uses previously saved language if there is any, if not uses phone locale as initial language.
* Disable default/already selected language from dialog box
* Get ListPreference key and act accordingly for each ListPreference.
* saves value chosen by user to shared preferences
* to remember later and recall MainActivity to reflect language changes
* @param keyListPreference
*/
private void prepareAppLanguages(final String keyListPreference) {
// Gets current language code from shared preferences
final String languageCode = getCurrentLanguageCode(keyListPreference);
final List<Language> recentLanguages = recentLanguagesDao.getRecentLanguages();
HashMap<Integer, String> selectedLanguages = new HashMap<>();
if (keyListPreference.equals("appUiDefaultLanguagePref")) {
assert languageCode != null;
if (languageCode.equals("")) {
selectedLanguages.put(0, Locale.getDefault().getLanguage());
} else {
selectedLanguages.put(0, languageCode);
}
} else if (keyListPreference.equals("descriptionDefaultLanguagePref")) {
assert languageCode != null;
if (languageCode.equals("")) {
selectedLanguages.put(0, Locale.getDefault().getLanguage());
} else {
selectedLanguages.put(0, languageCode);
}
}
LanguagesAdapter languagesAdapter = new LanguagesAdapter(
getActivity(),
selectedLanguages
);
Dialog dialog = new Dialog(getActivity());
dialog.setContentView(R.layout.dialog_select_language);
dialog.setCanceledOnTouchOutside(true);
dialog.getWindow().setLayout((int)(getActivity().getResources().getDisplayMetrics().widthPixels*0.90),
(int)(getActivity().getResources().getDisplayMetrics().heightPixels*0.90));
dialog.show();
EditText editText = dialog.findViewById(R.id.search_language);
ListView listView = dialog.findViewById(R.id.language_list);
languageHistoryListView = dialog.findViewById(R.id.language_history_list);
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches);
separator = dialog.findViewById(R.id.separator);
setUpRecentLanguagesSection(recentLanguages, selectedLanguages);
listView.setAdapter(languagesAdapter);
editText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence charSequence, int i, int i1,
int i2) {
hideRecentLanguagesSection();
}
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1,
int i2) {
languagesAdapter.getFilter().filter(charSequence);
}
@Override
public void afterTextChanged(Editable editable) {
}
});
languageHistoryListView.setOnItemClickListener((adapterView, view, position, id) -> {
onRecentLanguageClicked(keyListPreference, dialog, adapterView, position);
});
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i,
long l) {
String languageCode = ((LanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(i);
final String languageName = ((LanguagesAdapter) adapterView.getAdapter())
.getLanguageName(i);
final boolean isExists = recentLanguagesDao.findRecentLanguage(languageCode);
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode);
}
recentLanguagesDao.addRecentLanguage(new Language(languageName, languageCode));
saveLanguageValue(languageCode, keyListPreference);
Locale defLocale = createLocale(languageCode);
if(keyListPreference.equals("appUiDefaultLanguagePref")) {
appUiLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale));
setLocale(requireActivity(), languageCode);
getActivity().recreate();
final Intent intent = new Intent(getActivity(), MainActivity.class);
startActivity(intent);
}else {
descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale));
}
dialog.dismiss();
}
});
dialog.setOnDismissListener(
dialogInterface -> languagesAdapter.getFilter().filter(""));
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
* @param selectedLanguages selected languages
*/
private void setUpRecentLanguagesSection(List<Language> recentLanguages,
HashMap<Integer, String> selectedLanguages) {
if (recentLanguages.isEmpty()) {
languageHistoryListView.setVisibility(View.GONE);
recentLanguagesTextView.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
} else {
if (recentLanguages.size() > 5) {
for (int i = recentLanguages.size()-1; i >=5; i--) {
recentLanguagesDao
.deleteRecentLanguage(recentLanguages.get(i).getLanguageCode());
}
}
languageHistoryListView.setVisibility(View.VISIBLE);
recentLanguagesTextView.setVisibility(View.VISIBLE);
separator.setVisibility(View.VISIBLE);
final RecentLanguagesAdapter recentLanguagesAdapter
= new RecentLanguagesAdapter(
getActivity(),
recentLanguagesDao.getRecentLanguages(),
selectedLanguages);
languageHistoryListView.setAdapter(recentLanguagesAdapter);
}
}
/**
* Handles click event for recent language section
*/
private void onRecentLanguageClicked(String keyListPreference, Dialog dialog, AdapterView<?> adapterView,
int position) {
final String recentLanguageCode = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(position);
final String recentLanguageName = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageName(position);
final boolean isExists = recentLanguagesDao.findRecentLanguage(recentLanguageCode);
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(recentLanguageCode);
}
recentLanguagesDao.addRecentLanguage(
new Language(recentLanguageName, recentLanguageCode));
saveLanguageValue(recentLanguageCode, keyListPreference);
final Locale defLocale = createLocale(recentLanguageCode);
if (keyListPreference.equals("appUiDefaultLanguagePref")) {
appUiLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale));
setLocale(requireActivity(), recentLanguageCode);
getActivity().recreate();
final Intent intent = new Intent(getActivity(), MainActivity.class);
startActivity(intent);
} else {
descriptionLanguageListPreference.setSummary(defLocale.getDisplayLanguage(defLocale));
}
dialog.dismiss();
}
/**
* Remove the section of recent languages
*/
private void hideRecentLanguagesSection() {
languageHistoryListView.setVisibility(View.GONE);
recentLanguagesTextView.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
}
/**
* Changing the default app language with selected one and save it to SharedPreferences
*/
public void setLocale(final Activity activity, String userSelectedValue) {
if (userSelectedValue.equals("")) {
userSelectedValue = Locale.getDefault().getLanguage();
}
final Locale locale = createLocale(userSelectedValue);
Locale.setDefault(locale);
final Configuration configuration = new Configuration();
configuration.locale = locale;
activity.getBaseContext().getResources().updateConfiguration(configuration,
activity.getBaseContext().getResources().getDisplayMetrics());
final SharedPreferences.Editor editor = activity.getSharedPreferences("Settings", MODE_PRIVATE).edit();
editor.putString("language", userSelectedValue);
editor.apply();
}
/**
* Create Locale based on different types of language codes
* @param languageCode
* @return Locale and throws error for invalid language codes
*/
public static Locale createLocale(String languageCode) {
String[] parts = languageCode.split("-");
switch (parts.length) {
case 1:
return new Locale(parts[0]);
case 2:
return new Locale(parts[0], parts[1]);
case 3:
return new Locale(parts[0], parts[1], parts[2]);
default:
throw new IllegalArgumentException("Invalid language code: " + languageCode);
}
}
/**
* Save userselected language in List Preference
* @param userSelectedValue
* @param preferenceKey
*/
private void saveLanguageValue(final String userSelectedValue, final String preferenceKey) {
if (preferenceKey.equals("appUiDefaultLanguagePref")) {
defaultKvStore.putString(Prefs.APP_UI_LANGUAGE, userSelectedValue);
} else if (preferenceKey.equals("descriptionDefaultLanguagePref")) {
defaultKvStore.putString(Prefs.DESCRIPTION_LANGUAGE, userSelectedValue);
}
}
/**
* Gets current language code from shared preferences
* @param preferenceKey
* @return
*/
private String getCurrentLanguageCode(final String preferenceKey) {
if (preferenceKey.equals("appUiDefaultLanguagePref")) {
return defaultKvStore.getString(Prefs.APP_UI_LANGUAGE, "");
}
if (preferenceKey.equals("descriptionDefaultLanguagePref")) {
return defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "");
}
return null;
}
/**
* First checks for external storage permissions and then sends logs via email
*/
private void checkPermissionsAndSendLogs() {
if (PermissionUtils.hasPermission(getActivity(), PermissionUtils.PERMISSIONS_STORAGE)) {
commonsLogSender.send(getActivity(), null);
} else {
requestExternalStoragePermissions();
}
}
/**
* Requests external storage permissions and shows a toast stating that log collection has
* started
*/
private void requestExternalStoragePermissions() {
Dexter.withActivity(getActivity())
.withPermissions(PermissionUtils.PERMISSIONS_STORAGE)
.withListener(new MultiplePermissionsListener() {
@Override
public void onPermissionsChecked(MultiplePermissionsReport report) {
ViewUtil.showLongToast(getActivity(),
getResources().getString(R.string.log_collection_started));
}
@Override
public void onPermissionRationaleShouldBeShown(
List<PermissionRequest> permissions, PermissionToken token) {
}
})
.onSameThread()
.check();
}
}

View file

@ -0,0 +1,558 @@
package fr.free.nrw.commons.settings
import android.Manifest.permission
import android.app.Activity
import android.app.Dialog
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.AdapterView
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroupAdapter
import androidx.preference.PreferenceScreen
import androidx.preference.PreferenceViewHolder
import androidx.recyclerview.widget.RecyclerView.Adapter
import com.karumi.dexter.Dexter
import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.PermissionToken
import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.campaigns.CampaignView
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.logging.CommonsLogSender
import fr.free.nrw.commons.recentlanguages.Language
import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.upload.LanguagesAdapter
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.PermissionUtils
import fr.free.nrw.commons.utils.ViewUtil
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
class SettingsFragment : PreferenceFragmentCompat() {
@Inject
@field: Named("default_preferences")
lateinit var defaultKvStore: JsonKvStore
@Inject
lateinit var commonsLogSender: CommonsLogSender
@Inject
lateinit var recentLanguagesDao: RecentLanguagesDao
@Inject
lateinit var contributionController: ContributionController
@Inject
lateinit var locationManager: LocationServiceManager
private var themeListPreference: ListPreference? = null
private var descriptionLanguageListPreference: Preference? = null
private var appUiLanguageListPreference: Preference? = null
private var showDeletionButtonPreference: Preference? = null
private var keyLanguageListPreference: String? = null
private var recentLanguagesTextView: TextView? = null
private var separator: View? = null
private var languageHistoryListView: ListView? = null
private lateinit var inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>
private val GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content"
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks ->
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks)
}
}
/**
* to be called when the fragment creates preferences
* @param savedInstanceState the previously saved state
* @param rootKey the root key for preferences
*/
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
ApplicationlessInjection
.getInstance(requireActivity().applicationContext)
.commonsApplicationComponent
.inject(this)
// Set the preferences from an XML resource
setPreferencesFromResource(R.xml.preferences, rootKey)
themeListPreference = findPreference(Prefs.KEY_THEME_VALUE)
prepareTheme()
val multiSelectListPref: MultiSelectListPreference? = findPreference(
Prefs.MANAGED_EXIF_TAGS
)
multiSelectListPref?.setOnPreferenceChangeListener { _, newValue ->
if (newValue is HashSet<*> && !newValue.contains(getString(R.string.exif_tag_location)))
{
defaultKvStore.putBoolean("has_user_manually_removed_location", true)
}
true
}
val inAppCameraLocationPref: Preference? = findPreference("inAppCameraLocationPref")
inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue ->
val isInAppCameraLocationTurnedOn = newValue as Boolean
if (isInAppCameraLocationTurnedOn) {
createDialogsAndHandleLocationPermissions(requireActivity())
}
true
}
inAppCameraLocationPermissionLauncher = registerForActivityResult(
RequestMultiplePermissions()
) { result ->
var areAllGranted = true
for (b in result.values) {
areAllGranted = areAllGranted && b
}
if (
!areAllGranted
&&
shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)
) {
contributionController.handleShowRationaleFlowCameraLocation(
requireActivity(),
inAppCameraLocationPermissionLauncher,
cameraPickLauncherForResult
)
}
}
// Gets current language code from shared preferences
var languageCode: String?
appUiLanguageListPreference = findPreference("appUiDefaultLanguagePref")
appUiLanguageListPreference?.let { appUiLanguageListPreference ->
keyLanguageListPreference = appUiLanguageListPreference.key
languageCode = getCurrentLanguageCode(keyLanguageListPreference!!)
languageCode?.let { code ->
if (code.isEmpty()) {
// If current language code is empty, means none selected by user yet so use
// phone locale
appUiLanguageListPreference.summary = Locale.getDefault().displayLanguage
} else {
// If any language is selected by user previously, use it
val defLocale = createLocale(code)
appUiLanguageListPreference.summary = defLocale.getDisplayLanguage(defLocale)
}
}
appUiLanguageListPreference.setOnPreferenceClickListener {
prepareAppLanguages(keyLanguageListPreference!!)
true
}
}
descriptionLanguageListPreference = findPreference("descriptionDefaultLanguagePref")
descriptionLanguageListPreference?.let { descriptionLanguageListPreference ->
languageCode = getCurrentLanguageCode(descriptionLanguageListPreference.key)
languageCode?.let { code ->
if (code.isEmpty()) {
// If current language code is empty, means none selected by user yet so use
// phone locale
descriptionLanguageListPreference.summary = Locale.getDefault().displayLanguage
} else {
// If any language is selected by user previously, use it
val defLocale = createLocale(code)
descriptionLanguageListPreference.summary = defLocale.getDisplayLanguage(
defLocale
)
}
}
descriptionLanguageListPreference.setOnPreferenceClickListener {
prepareAppLanguages(it.key)
true
}
}
showDeletionButtonPreference = findPreference("displayDeletionButton")
showDeletionButtonPreference?.setOnPreferenceChangeListener { _, newValue ->
val isEnabled = newValue as Boolean
// Save preference when user toggles the button
defaultKvStore.putBoolean("displayDeletionButton", isEnabled)
true
}
val betaTesterPreference: Preference? = findPreference("becomeBetaTester")
betaTesterPreference?.setOnPreferenceClickListener {
Utils.handleWebUrl(requireActivity(), Uri.parse(getString(R.string.beta_opt_in_link)))
true
}
val sendLogsPreference: Preference? = findPreference("sendLogFile")
sendLogsPreference?.setOnPreferenceClickListener {
checkPermissionsAndSendLogs()
true
}
val documentBasedPickerPreference: Preference? = findPreference(
"openDocumentPhotoPickerPref"
)
documentBasedPickerPreference?.setOnPreferenceChangeListener { _, newValue ->
val isGetContentPickerTurnedOn = newValue as Boolean
if (!isGetContentPickerTurnedOn) {
showLocationLossWarning()
}
true
}
// Disable some settings when not logged in.
if (defaultKvStore.getBoolean("login_skipped", false)) {
findPreference<Preference>("useExternalStorage")?.isEnabled = false
findPreference<Preference>("useAuthorName")?.isEnabled = false
findPreference<Preference>("displayNearbyCardView")?.isEnabled = false
findPreference<Preference>("descriptionDefaultLanguagePref")?.isEnabled = false
findPreference<Preference>("displayLocationPermissionForCardView")?.isEnabled = false
findPreference<Preference>(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE)?.isEnabled = false
findPreference<Preference>("managed_exif_tags")?.isEnabled = false
findPreference<Preference>("openDocumentPhotoPickerPref")?.isEnabled = false
findPreference<Preference>("inAppCameraLocationPref")?.isEnabled = false
}
}
/**
* Asks users to provide location access
*
* @param activity
*/
private fun createDialogsAndHandleLocationPermissions(activity: Activity) {
inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION))
}
/**
* On some devices, the new Photo Picker with GET_CONTENT takeover
* redacts location tags from EXIF metadata
*
* Show warning to the user when ACTION_GET_CONTENT intent is enabled
*/
private fun showLocationLossWarning() {
DialogUtil.showAlertDialog(
requireActivity(),
null,
getString(R.string.location_loss_warning),
getString(R.string.ok),
getString(R.string.read_help_link),
{ },
{ Utils.handleWebUrl(requireContext(), Uri.parse(GET_CONTENT_PICKER_HELP_URL)) },
null,
true
)
}
override fun onCreateAdapter(preferenceScreen: PreferenceScreen): Adapter<PreferenceViewHolder>
{
return object : PreferenceGroupAdapter(preferenceScreen) {
override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
val preference = getItem(position)
val iconFrame: View? = holder.itemView.findViewById(R.id.icon_frame)
iconFrame?.visibility = View.GONE
}
}
}
/**
* Sets the theme pref
*/
private fun prepareTheme() {
themeListPreference?.setOnPreferenceChangeListener { _, _ ->
requireActivity().recreate()
true
}
}
/**
* Prepare and Show language selection dialog box
* Uses previously saved language if there is any, if not uses phone locale as initial language.
* Disable default/already selected language from dialog box
* Get ListPreference key and act accordingly for each ListPreference.
* saves value chosen by user to shared preferences
* to remember later and recall MainActivity to reflect language changes
* @param keyListPreference
*/
private fun prepareAppLanguages(keyListPreference: String) {
// Gets current language code from shared preferences
val languageCode = getCurrentLanguageCode(keyListPreference)
val recentLanguages = recentLanguagesDao.getRecentLanguages()
val selectedLanguages = hashMapOf<Int, String>()
if (keyListPreference == "appUiDefaultLanguagePref") {
if (languageCode.isNullOrEmpty()) {
selectedLanguages[0] = Locale.getDefault().language
} else {
selectedLanguages[0] = languageCode
}
} else if (keyListPreference == "descriptionDefaultLanguagePref") {
if (languageCode.isNullOrEmpty()) {
selectedLanguages[0] = Locale.getDefault().language
} else {
selectedLanguages[0] = languageCode
}
}
val languagesAdapter = LanguagesAdapter(requireActivity(), selectedLanguages)
val dialog = Dialog(requireActivity())
dialog.setContentView(R.layout.dialog_select_language)
dialog.setCanceledOnTouchOutside(true)
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.90).toInt(),
(resources.displayMetrics.heightPixels * 0.90).toInt()
)
dialog.show()
val editText: EditText = dialog.findViewById(R.id.search_language)
val listView: ListView = dialog.findViewById(R.id.language_list)
languageHistoryListView = dialog.findViewById(R.id.language_history_list)
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches)
separator = dialog.findViewById(R.id.separator)
setUpRecentLanguagesSection(recentLanguages, selectedLanguages)
listView.adapter = languagesAdapter
editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) {
hideRecentLanguagesSection()
}
override fun onTextChanged(charSequence: CharSequence, start: Int, before: Int, count: Int) {
languagesAdapter.filter.filter(charSequence)
}
override fun afterTextChanged(editable: Editable?) {}
})
languageHistoryListView?.setOnItemClickListener { adapterView, _, position, _ ->
onRecentLanguageClicked(keyListPreference, dialog, adapterView, position)
}
listView.setOnItemClickListener { adapterView, _, position, _ ->
val lCode = (adapterView.adapter as LanguagesAdapter).getLanguageCode(position)
val languageName = (adapterView.adapter as LanguagesAdapter).getLanguageName(position)
val isExists = recentLanguagesDao.findRecentLanguage(lCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(lCode)
}
recentLanguagesDao.addRecentLanguage(Language(languageName, lCode))
saveLanguageValue(lCode, keyListPreference)
val defLocale = createLocale(lCode)
if (keyListPreference == "appUiDefaultLanguagePref") {
appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
setLocale(requireActivity(), lCode)
requireActivity().recreate()
val intent = Intent(requireActivity(), MainActivity::class.java)
startActivity(intent)
} else {
descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
}
dialog.dismiss()
}
dialog.setOnDismissListener { languagesAdapter.filter.filter("") }
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
* @param selectedLanguages selected languages
*/
private fun setUpRecentLanguagesSection(
recentLanguages: List<Language>,
selectedLanguages: HashMap<Int, String>
) {
if (recentLanguages.isEmpty()) {
languageHistoryListView?.visibility = View.GONE
recentLanguagesTextView?.visibility = View.GONE
separator?.visibility = View.GONE
} else {
if (recentLanguages.size > 5) {
for (i in recentLanguages.size - 1 downTo 5) {
recentLanguagesDao.deleteRecentLanguage(recentLanguages[i].languageCode)
}
}
languageHistoryListView?.visibility = View.VISIBLE
recentLanguagesTextView?.visibility = View.VISIBLE
separator?.visibility = View.VISIBLE
val recentLanguagesAdapter = RecentLanguagesAdapter(
requireActivity(),
recentLanguagesDao.getRecentLanguages(),
selectedLanguages
)
languageHistoryListView?.adapter = recentLanguagesAdapter
}
}
/**
* Handles click event for recent language section
*/
private fun onRecentLanguageClicked(
keyListPreference: String,
dialog: Dialog,
adapterView: AdapterView<*>,
position: Int
) {
val recentLanguageCode = (adapterView.adapter as RecentLanguagesAdapter).getLanguageCode(position)
val recentLanguageName = (adapterView.adapter as RecentLanguagesAdapter).getLanguageName(position)
val isExists = recentLanguagesDao.findRecentLanguage(recentLanguageCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(recentLanguageCode)
}
recentLanguagesDao.addRecentLanguage(Language(recentLanguageName, recentLanguageCode))
saveLanguageValue(recentLanguageCode, keyListPreference)
val defLocale = createLocale(recentLanguageCode)
if (keyListPreference == "appUiDefaultLanguagePref") {
appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
setLocale(requireActivity(), recentLanguageCode)
requireActivity().recreate()
val intent = Intent(requireActivity(), MainActivity::class.java)
startActivity(intent)
} else {
descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
}
dialog.dismiss()
}
/**
* Remove the section of recent languages
*/
private fun hideRecentLanguagesSection() {
languageHistoryListView?.visibility = View.GONE
recentLanguagesTextView?.visibility = View.GONE
separator?.visibility = View.GONE
}
/**
* Changing the default app language with selected one and save it to SharedPreferences
*/
fun setLocale(activity: Activity, userSelectedValue: String) {
var selectedLanguage = userSelectedValue
if (selectedLanguage == "") {
selectedLanguage = Locale.getDefault().language
}
val locale = createLocale(selectedLanguage)
Locale.setDefault(locale)
val configuration = Configuration()
configuration.locale = locale
activity.baseContext.resources.updateConfiguration(configuration, activity.baseContext.resources.displayMetrics)
val editor = activity.getSharedPreferences("Settings", MODE_PRIVATE).edit()
editor.putString("language", selectedLanguage)
editor.apply()
}
companion object {
/**
* Create Locale based on different types of language codes
* @param languageCode
* @return Locale and throws error for invalid language codes
*/
fun createLocale(languageCode: String): Locale {
val parts = languageCode.split("-")
return when (parts.size) {
1 -> Locale(parts[0])
2 -> Locale(parts[0], parts[1])
3 -> Locale(parts[0], parts[1], parts[2])
else -> throw IllegalArgumentException("Invalid language code: $languageCode")
}
}
}
/**
* Save userSelected language in List Preference
* @param userSelectedValue
* @param preferenceKey
*/
private fun saveLanguageValue(userSelectedValue: String, preferenceKey: String) {
when (preferenceKey) {
"appUiDefaultLanguagePref" -> defaultKvStore.putString(Prefs.APP_UI_LANGUAGE, userSelectedValue)
"descriptionDefaultLanguagePref" -> defaultKvStore.putString(Prefs.DESCRIPTION_LANGUAGE, userSelectedValue)
}
}
/**
* Gets current language code from shared preferences
* @param preferenceKey
* @return
*/
private fun getCurrentLanguageCode(preferenceKey: String): String? {
return when (preferenceKey) {
"appUiDefaultLanguagePref" -> defaultKvStore.getString(
Prefs.APP_UI_LANGUAGE, ""
)
"descriptionDefaultLanguagePref" -> defaultKvStore.getString(
Prefs.DESCRIPTION_LANGUAGE, ""
)
else -> null
}
}
/**
* First checks for external storage permissions and then sends logs via email
*/
private fun checkPermissionsAndSendLogs() {
if (
PermissionUtils.hasPermission(
requireActivity(),
PermissionUtils.PERMISSIONS_STORAGE
)
) {
commonsLogSender.send(requireActivity(), null)
} else {
requestExternalStoragePermissions()
}
}
/**
* Requests external storage permissions and shows a toast stating that log collection has
* started
*/
private fun requestExternalStoragePermissions() {
Dexter.withActivity(requireActivity())
.withPermissions(*PermissionUtils.PERMISSIONS_STORAGE)
.withListener(object : MultiplePermissionsListener {
override fun onPermissionsChecked(report: MultiplePermissionsReport) {
ViewUtil.showLongToast(requireActivity(), getString(R.string.log_collection_started))
}
override fun onPermissionRationaleShouldBeShown(
permissions: List<PermissionRequest>, token: PermissionToken
) {
// No action needed
}
})
.onSameThread()
.check()
}
}

View file

@ -1,66 +0,0 @@
package fr.free.nrw.commons.theme;
import android.content.res.Configuration;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.WindowManager;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.di.CommonsDaggerAppCompatActivity;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import io.reactivex.disposables.CompositeDisposable;
public abstract class BaseActivity extends CommonsDaggerAppCompatActivity {
@Inject
@Named("default_preferences")
public JsonKvStore defaultKvStore;
@Inject
SystemThemeUtils systemThemeUtils;
protected CompositeDisposable compositeDisposable = new CompositeDisposable();
protected boolean wasPreviouslyDarkTheme;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
wasPreviouslyDarkTheme = systemThemeUtils.isDeviceInNightMode();
setTheme(wasPreviouslyDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
float fontScale = android.provider.Settings.System.getFloat(
getBaseContext().getContentResolver(),
android.provider.Settings.System.FONT_SCALE,
1f);
adjustFontScale(getResources().getConfiguration(), fontScale);
}
@Override
protected void onResume() {
// Restart activity if theme is changed
if (wasPreviouslyDarkTheme != systemThemeUtils.isDeviceInNightMode()) {
recreate();
}
super.onResume();
}
@Override
protected void onDestroy() {
super.onDestroy();
compositeDisposable.clear();
}
/**
* Apply fontScale on device
*/
public void adjustFontScale(Configuration configuration, float scale) {
configuration.fontScale = scale;
final DisplayMetrics metrics = getResources().getDisplayMetrics();
final WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
wm.getDefaultDisplay().getMetrics(metrics);
metrics.scaledDensity = configuration.fontScale * metrics.density;
getBaseContext().getResources().updateConfiguration(configuration, metrics);
}
}

View file

@ -0,0 +1,65 @@
package fr.free.nrw.commons.theme
import android.content.res.Configuration
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.WindowManager
import javax.inject.Inject
import javax.inject.Named
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.CommonsDaggerAppCompatActivity
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.utils.SystemThemeUtils
import io.reactivex.disposables.CompositeDisposable
abstract class BaseActivity : CommonsDaggerAppCompatActivity() {
@Inject
@field:Named("default_preferences")
lateinit var defaultKvStore: JsonKvStore
@Inject
lateinit var systemThemeUtils: SystemThemeUtils
protected val compositeDisposable = CompositeDisposable()
protected var wasPreviouslyDarkTheme: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
wasPreviouslyDarkTheme = systemThemeUtils.isDeviceInNightMode()
setTheme(if (wasPreviouslyDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
val fontScale = android.provider.Settings.System.getFloat(
baseContext.contentResolver,
android.provider.Settings.System.FONT_SCALE,
1f
)
adjustFontScale(resources.configuration, fontScale)
}
override fun onResume() {
// Restart activity if theme is changed
if (wasPreviouslyDarkTheme != systemThemeUtils.isDeviceInNightMode()) {
recreate()
}
super.onResume()
}
override fun onDestroy() {
super.onDestroy()
compositeDisposable.clear()
}
/**
* Apply fontScale on device
*/
fun adjustFontScale(configuration: Configuration, scale: Float) {
configuration.fontScale = scale
val metrics = resources.displayMetrics
val wm = getSystemService(WINDOW_SERVICE) as WindowManager
wm.defaultDisplay.getMetrics(metrics)
metrics.scaledDensity = configuration.fontScale * metrics.density
baseContext.resources.updateConfiguration(configuration, metrics)
}
}

View file

@ -1,65 +0,0 @@
package fr.free.nrw.commons.ui;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build.VERSION;
import android.util.AttributeSet;
import com.google.android.material.textfield.TextInputEditText;
import fr.free.nrw.commons.R;
public class PasteSensitiveTextInputEditText extends TextInputEditText {
private boolean formattingAllowed = true;
public PasteSensitiveTextInputEditText(final Context context) {
super(context);
}
public PasteSensitiveTextInputEditText(final Context context, final AttributeSet attrs) {
super(context, attrs);
formattingAllowed = extractFormattingAttribute(context, attrs);
}
@Override
public boolean onTextContextMenuItem(int id) {
// if not paste command, or formatting is allowed, return default
if(id != android.R.id.paste || formattingAllowed){
return super.onTextContextMenuItem(id);
}
// if its paste and formatting not allowed
boolean proceeded;
if(VERSION.SDK_INT >= 23) {
proceeded = super.onTextContextMenuItem(android.R.id.pasteAsPlainText);
}else {
proceeded = super.onTextContextMenuItem(id);
if (proceeded && getText() != null) {
// rewrite with plain text so formatting is lost
setText(getText().toString());
setSelection(getText().length());
}
}
return proceeded;
}
private boolean extractFormattingAttribute(Context context, AttributeSet attrs){
boolean formatAllowed = true;
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs, R.styleable.PasteSensitiveTextInputEditText, 0, 0);
try {
formatAllowed = a.getBoolean(
R.styleable.PasteSensitiveTextInputEditText_allowFormatting, true);
} finally {
a.recycle();
}
return formatAllowed;
}
public void setFormattingAllowed(boolean formattingAllowed){
this.formattingAllowed = formattingAllowed;
}
}

View file

@ -0,0 +1,60 @@
package fr.free.nrw.commons.ui
import android.content.Context
import android.content.res.TypedArray
import android.os.Build
import android.os.Build.VERSION
import android.util.AttributeSet
import com.google.android.material.textfield.TextInputEditText
import fr.free.nrw.commons.R
class PasteSensitiveTextInputEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : TextInputEditText(context, attrs) {
private var formattingAllowed: Boolean = true
init {
if (attrs != null) {
formattingAllowed = extractFormattingAttribute(context, attrs)
}
}
override fun onTextContextMenuItem(id: Int): Boolean {
// if not paste command, or formatting is allowed, return default
if (id != android.R.id.paste || formattingAllowed) {
return super.onTextContextMenuItem(id)
}
// if it's paste and formatting not allowed
val proceeded: Boolean = if (VERSION.SDK_INT >= 23) {
super.onTextContextMenuItem(android.R.id.pasteAsPlainText)
} else {
val success = super.onTextContextMenuItem(id)
if (success && text != null) {
// rewrite with plain text so formatting is lost
setText(text.toString())
setSelection(text?.length ?: 0)
}
success
}
return proceeded
}
private fun extractFormattingAttribute(context: Context, attrs: AttributeSet): Boolean {
val a = context.theme.obtainStyledAttributes(
attrs, R.styleable.PasteSensitiveTextInputEditText, 0, 0
)
return try {
a.getBoolean(R.styleable.PasteSensitiveTextInputEditText_allowFormatting, true)
} finally {
a.recycle()
}
}
fun setFormattingAllowed(formattingAllowed: Boolean) {
this.formattingAllowed = formattingAllowed
}
}

View file

@ -1,36 +0,0 @@
package fr.free.nrw.commons.ui.widget;
import android.content.Context;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import androidx.appcompat.widget.AppCompatTextView;
import fr.free.nrw.commons.utils.StringUtil;
/**
* An {@link AppCompatTextView} which formats the text to HTML displayable text and makes any
* links clickable.
*/
public class HtmlTextView extends AppCompatTextView {
/**
* Constructs a new instance of HtmlTextView
* @param context the context of the view
* @param attrs the set of attributes for the view
*/
public HtmlTextView(Context context, AttributeSet attrs) {
super(context, attrs);
setMovementMethod(LinkMovementMethod.getInstance());
setText(StringUtil.fromHtml(getText().toString()));
}
/**
* Sets the text to be displayed
* @param newText the text to be displayed
*/
public void setHtmlText(String newText) {
setText(StringUtil.fromHtml(newText));
}
}

View file

@ -0,0 +1,32 @@
package fr.free.nrw.commons.ui.widget
import android.content.Context
import android.text.method.LinkMovementMethod
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import fr.free.nrw.commons.utils.StringUtil
/**
* An [AppCompatTextView] which formats the text to HTML displayable text and makes any
* links clickable.
*/
class HtmlTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
init {
movementMethod = LinkMovementMethod.getInstance()
text = StringUtil.fromHtml(text.toString())
}
/**
* Sets the text to be displayed
* @param newText the text to be displayed
*/
fun setHtmlText(newText: String) {
text = StringUtil.fromHtml(newText)
}
}

View file

@ -1,70 +0,0 @@
package fr.free.nrw.commons.ui.widget;
import android.app.Dialog;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.Gravity;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
/**
* a formatted dialog fragment
* This class is used by NearbyInfoDialog
*/
public abstract class OverlayDialog extends DialogFragment {
/**
* creates a DialogFragment with the correct style and theme
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_FRAME, android.R.style.Theme_Holo_Light);
}
/**
* When the view is created, sets the dialog layout to full screen
*
* @param view the view being used
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
setDialogLayoutToFullScreen();
super.onViewCreated(view, savedInstanceState);
}
/**
* sets the dialog layout to fullscreen
*/
private void setDialogLayoutToFullScreen() {
Window window = getDialog().getWindow();
WindowManager.LayoutParams wlp = window.getAttributes();
window.requestFeature(Window.FEATURE_NO_TITLE);
wlp.gravity = Gravity.BOTTOM;
wlp.width = WindowManager.LayoutParams.MATCH_PARENT;
wlp.height = WindowManager.LayoutParams.MATCH_PARENT;
window.setAttributes(wlp);
}
/**
* builds custom dialog container
*
* @param savedInstanceState the previously saved state
* @return the dialog
*/
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Dialog dialog = super.onCreateDialog(savedInstanceState);
Window window = dialog.getWindow();
window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
return dialog;
}
}

View file

@ -0,0 +1,64 @@
package fr.free.nrw.commons.ui.widget
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.Window
import android.view.WindowManager
import androidx.fragment.app.DialogFragment
/**
* A formatted dialog fragment
* This class is used by NearbyInfoDialog
*/
abstract class OverlayDialog : DialogFragment() {
/**
* Creates a DialogFragment with the correct style and theme
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, android.R.style.Theme_Holo_Light)
}
/**
* When the view is created, sets the dialog layout to full screen
*
* @param view the view being used
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setDialogLayoutToFullScreen()
super.onViewCreated(view, savedInstanceState)
}
/**
* Sets the dialog layout to fullscreen
*/
private fun setDialogLayoutToFullScreen() {
val window = dialog?.window ?: return
val wlp = window.attributes
window.requestFeature(Window.FEATURE_NO_TITLE)
wlp.gravity = Gravity.BOTTOM
wlp.width = WindowManager.LayoutParams.MATCH_PARENT
wlp.height = WindowManager.LayoutParams.MATCH_PARENT
window.attributes = wlp
}
/**
* Builds custom dialog container
*
* @param savedInstanceState the previously saved state
* @return the dialog
*/
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return dialog
}
}

View file

@ -1,8 +1,8 @@
package fr.free.nrw.commons.upload;
import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS;
import static fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE;
import static fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction;
import static fr.free.nrw.commons.utils.PermissionUtils.getPERMISSIONS_STORAGE;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY;
@ -32,7 +32,6 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager;
@ -277,7 +276,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
public void checkStoragePermissions() {
// Check if all required permissions are granted
final boolean hasAllPermissions = PermissionUtils.hasPermission(this, PERMISSIONS_STORAGE);
final boolean hasAllPermissions = PermissionUtils.hasPermission(this, getPERMISSIONS_STORAGE());
final boolean hasPartialAccess = PermissionUtils.hasPartialAccess(this);
if (hasAllPermissions || hasPartialAccess) {
// All required permissions are granted, so enable UI elements and perform actions
@ -297,7 +296,7 @@ public class UploadActivity extends BaseActivity implements UploadContract.View,
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale_for_image_share,
PERMISSIONS_STORAGE);
getPERMISSIONS_STORAGE());
}
}
/* If all permissions are not granted and a dialog is already showing on screen

View file

@ -22,7 +22,6 @@ import android.widget.ListView;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;

View file

@ -61,7 +61,7 @@ class CategoriesPresenter
.doOnNext {
view.showProgress(true)
}.switchMap(::searchResults)
.map { repository.selectedCategories + it }
.map { repository.getSelectedCategories() + it }
.map { it.distinctBy { categoryItem -> categoryItem.name } }
.observeOn(mainThreadScheduler)
.subscribe(
@ -89,7 +89,7 @@ class CategoriesPresenter
private fun searchResults(term: String): Observable<List<CategoryItem>>? {
if (media == null) {
return repository
.searchAll(term, getImageTitleList(), repository.selectedDepictions)
.searchAll(term, getImageTitleList(), repository.getSelectedDepictions())
.subscribeOn(ioScheduler)
.map {
it.filter { categoryItem ->
@ -101,13 +101,13 @@ class CategoriesPresenter
return Observable
.zip(
repository
.getCategories(repository.selectedExistingCategories)
.getCategories(repository.getSelectedExistingCategories())
.map { list ->
list.map {
CategoryItem(it.name, it.description, it.thumbnail, true)
}
},
repository.searchAll(term, getImageTitleList(), repository.selectedDepictions),
repository.searchAll(term, getImageTitleList(), repository.getSelectedDepictions()),
) { it1, it2 ->
it1 + it2
}.subscribeOn(ioScheduler)
@ -138,7 +138,7 @@ class CategoriesPresenter
* @return
*/
private fun getImageTitleList(): List<String> =
repository.uploads
repository.getUploads()
.map { it.uploadMediaDetails[0].captionText }
.filterNot { TextUtils.isEmpty(it) }
@ -146,7 +146,7 @@ class CategoriesPresenter
* Verifies the number of categories selected, prompts the user if none selected
*/
override fun verifyCategories() {
val selectedCategories = repository.selectedCategories
val selectedCategories = repository.getSelectedCategories()
if (selectedCategories.isNotEmpty()) {
repository.setSelectedCategories(selectedCategories.map { it.name })
view.goToNextScreen()
@ -173,14 +173,14 @@ class CategoriesPresenter
) {
this.view = view
this.media = media
repository.selectedExistingCategories = view.existingCategories
repository.setSelectedExistingCategories(view.existingCategories)
compositeDisposable.add(
searchTerms
.observeOn(mainThreadScheduler)
.doOnNext {
view.showProgress(true)
}.switchMap(::searchResults)
.map { repository.selectedCategories + it }
.map { repository.getSelectedCategories() + it }
.map { it.distinctBy { categoryItem -> categoryItem.name } }
.observeOn(mainThreadScheduler)
.subscribe(
@ -218,13 +218,21 @@ class CategoriesPresenter
wikiText: String,
) {
// check if view.existingCategories is null
if (repository.selectedCategories.isNotEmpty() ||
(view.existingCategories != null && repository.selectedExistingCategories.size != view.existingCategories.size)
if (
repository.getSelectedCategories().isNotEmpty()
||
(
view.existingCategories != null
&&
repository.getSelectedExistingCategories().size
!=
view.existingCategories.size
)
) {
val selectedCategories: MutableList<String> =
(
repository.selectedCategories.map { it.name }.toMutableList() +
repository.selectedExistingCategories
repository.getSelectedCategories().map { it.name }.toMutableList() +
repository.getSelectedExistingCategories()
).toMutableList()
if (selectedCategories.isNotEmpty()) {
@ -305,7 +313,7 @@ class CategoriesPresenter
override fun selectCategories() {
compositeDisposable.add(
repository.placeCategories
repository.getPlaceCategories()
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(::selectNewCategories),

View file

@ -93,14 +93,14 @@ class DepictsPresenter
return repository
.searchAllEntities(querystring)
.subscribeOn(ioScheduler)
.map { repository.selectedDepictions + it + recentDepictedItemList + controller.loadFavoritesItems() }
.map { repository.getSelectedDepictions() + it + recentDepictedItemList + controller.loadFavoritesItems() }
.map { it.filterNot { item -> WikidataDisambiguationItems.isDisambiguationItem(item.instanceOfs) } }
.map { it.distinctBy(DepictedItem::id) }
} else {
return Flowable
.zip(
repository
.getDepictions(repository.selectedExistingDepictions)
.getDepictions(repository.getSelectedExistingDepictions())
.map { list ->
list.map {
DepictedItem(
@ -118,7 +118,7 @@ class DepictsPresenter
) { it1, it2 ->
it1 + it2
}.subscribeOn(ioScheduler)
.map { repository.selectedDepictions + it + recentDepictedItemList + controller.loadFavoritesItems() }
.map { repository.getSelectedDepictions() + it + recentDepictedItemList + controller.loadFavoritesItems() }
.map { it.filterNot { item -> WikidataDisambiguationItems.isDisambiguationItem(item.instanceOfs) } }
.map { it.distinctBy(DepictedItem::id) }
}
@ -135,7 +135,7 @@ class DepictsPresenter
*/
override fun selectPlaceDepictions() {
compositeDisposable.add(
repository.placeDepictions
repository.getPlaceDepictions()
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(::selectNewDepictions),
@ -188,10 +188,10 @@ class DepictsPresenter
* from the depiction list
*/
override fun verifyDepictions() {
if (repository.selectedDepictions.isNotEmpty()) {
if (repository.getSelectedDepictions().isNotEmpty()) {
if (::depictsDao.isInitialized) {
// save all the selected Depicted item in room Database
depictsDao.savingDepictsInRoomDataBase(repository.selectedDepictions)
depictsDao.savingDepictsInRoomDataBase(repository.getSelectedDepictions())
}
view.goToNextScreen()
} else {
@ -205,20 +205,20 @@ class DepictsPresenter
*/
@SuppressLint("CheckResult")
override fun updateDepictions(media: Media) {
if (repository.selectedDepictions.isNotEmpty() ||
repository.selectedExistingDepictions.size != view.existingDepictions.size
if (repository.getSelectedDepictions().isNotEmpty() ||
repository.getSelectedExistingDepictions().size != view.existingDepictions.size
) {
view.showProgressDialog()
val selectedDepictions: MutableList<String> =
(
repository.selectedDepictions.map { it.id }.toMutableList() +
repository.selectedExistingDepictions
repository.getSelectedDepictions().map { it.id }.toMutableList() +
repository.getSelectedExistingDepictions()
).toMutableList()
if (selectedDepictions.isNotEmpty()) {
if (::depictsDao.isInitialized) {
// save all the selected Depicted item in room Database
depictsDao.savingDepictsInRoomDataBase(repository.selectedDepictions)
depictsDao.savingDepictsInRoomDataBase(repository.getSelectedDepictions())
}
compositeDisposable.add(
@ -254,7 +254,7 @@ class DepictsPresenter
) {
this.view = view
this.media = media
repository.selectedExistingDepictions = view.existingDepictions
repository.setSelectedExistingDepictions(view.existingDepictions)
compositeDisposable.add(
searchTerm
.observeOn(mainThreadScheduler)

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.upload.mediaDetails;
import static android.app.Activity.RESULT_OK;
import static fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags;
import android.annotation.SuppressLint;
import android.app.Activity;
@ -45,6 +44,7 @@ import fr.free.nrw.commons.upload.UploadBaseFragment;
import fr.free.nrw.commons.upload.UploadItem;
import fr.free.nrw.commons.upload.UploadMediaDetail;
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter;
import fr.free.nrw.commons.utils.ActivityUtils;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.ImageUtils;
import fr.free.nrw.commons.utils.NetworkUtils;
@ -208,7 +208,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
try {
if(!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, getActivity())) {
startActivityWithFlags(
ActivityUtils.startActivityWithFlags(
getActivity(), MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}

View file

@ -39,7 +39,7 @@ class DepictModel
for (place in places) {
place.wikiDataEntityId?.let { qids.add(it) }
}
repository.uploads.forEach { item ->
repository.getUploads().forEach { item ->
if (item.gpsCoords != null && item.gpsCoords.imageCoordsExists) {
Coordinates2Country
.countryQID(

View file

@ -47,6 +47,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Date
import java.util.Random
import java.util.regex.Pattern
import javax.inject.Inject
@ -548,33 +549,30 @@ class UploadWorker(
}
private fun findUniqueFileName(fileName: String): String {
var sequenceFileName: String?
var sequenceNumber = 1
while (true) {
sequenceFileName =
if (sequenceNumber == 1) {
fileName
} else {
if (fileName.indexOf('.') == -1) {
"$fileName $sequenceNumber"
} else {
val regex =
Pattern.compile("^(.*)(\\..+?)$")
val regexMatcher = regex.matcher(fileName)
regexMatcher.replaceAll("$1 $sequenceNumber$2")
}
}
if (!mediaClient
var sequenceFileName: String? = fileName
val random = Random()
// Loops until sequenceFileName does not match any existing file names
while (mediaClient
.checkPageExistsUsingTitle(
String.format(
"File:%s",
sequenceFileName,
),
).blockingGet()
) {
break
).blockingGet()) {
// Generate a random 5-character alphanumeric string
val randomHash = (random.nextInt(90000) + 10000).toString()
sequenceFileName =
if (fileName.indexOf('.') == -1) {
"$fileName #$randomHash"
} else {
val regex =
Pattern.compile("^(.*)(\\..+?)$")
val regexMatcher = regex.matcher(fileName)
regexMatcher.replaceAll("$1 #$randomHash")
}
sequenceNumber++
}
return sequenceFileName!!
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.utils;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
public class AbstractTextWatcher implements TextWatcher {
private final TextChange textChange;
public AbstractTextWatcher(@NonNull TextChange textChange) {
this.textChange = textChange;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
textChange.onTextChanged(s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
public interface TextChange {
void onTextChanged(String value);
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.utils
import android.text.Editable
import android.text.TextWatcher
class AbstractTextWatcher(
private val textChange: TextChange
) : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// No-op
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
textChange.onTextChanged(s.toString())
}
override fun afterTextChanged(s: Editable?) {
// No-op
}
interface TextChange {
fun onTextChanged(value: String)
}
}

View file

@ -1,15 +0,0 @@
package fr.free.nrw.commons.utils;
import android.content.Context;
import android.content.Intent;
public class ActivityUtils {
public static <T> void startActivityWithFlags(Context context, Class<T> cls, int... flags) {
Intent intent = new Intent(context, cls);
for (int flag : flags) {
intent.addFlags(flag);
}
context.startActivity(intent);
}
}

View file

@ -0,0 +1,16 @@
package fr.free.nrw.commons.utils
import android.content.Context
import android.content.Intent
object ActivityUtils {
@JvmStatic
fun <T> startActivityWithFlags(context: Context, cls: Class<T>, vararg flags: Int) {
val intent = Intent(context, cls)
for (flag in flags) {
intent.addFlags(flag)
}
context.startActivity(intent)
}
}

View file

@ -1,44 +0,0 @@
package fr.free.nrw.commons.utils;
import java.text.SimpleDateFormat;
import java.util.Locale;
import java.util.TimeZone;
/**
* Provides util functions for formatting date time
* Most of our formatting needs are addressed by the data library's DateUtil class
* Methods should be added here only if DateUtil class doesn't provide for it already
*/
public class CommonsDateUtil {
/**
* Gets SimpleDateFormat for short date pattern
* @return simpledateformat
*/
public static SimpleDateFormat getIso8601DateFormatShort() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.ROOT);
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
return simpleDateFormat;
}
/**
* Gets SimpleDateFormat for date pattern returned by Media object
* @return simpledateformat
*/
public static SimpleDateFormat getMediaSimpleDateFormat() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT);
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
return simpleDateFormat;
}
/**
* Gets the timestamp pattern for a date
* @return timestamp
*/
public static SimpleDateFormat getIso8601DateFormatTimestamp() {
final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'",
Locale.ROOT);
simpleDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
return simpleDateFormat;
}
}

View file

@ -0,0 +1,46 @@
package fr.free.nrw.commons.utils
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/**
* Provides util functions for formatting date time.
* Most of our formatting needs are addressed by the data library's DateUtil class.
* Methods should be added here only if DateUtil class doesn't provide for it already.
*/
object CommonsDateUtil {
/**
* Gets SimpleDateFormat for short date pattern.
* @return simpleDateFormat
*/
@JvmStatic
fun getIso8601DateFormatShort(): SimpleDateFormat {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
return simpleDateFormat
}
/**
* Gets SimpleDateFormat for date pattern returned by Media object.
* @return simpleDateFormat
*/
@JvmStatic
fun getMediaSimpleDateFormat(): SimpleDateFormat {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
return simpleDateFormat
}
/**
* Gets the timestamp pattern for a date.
* @return timestamp
*/
@JvmStatic
fun getIso8601DateFormatTimestamp(): SimpleDateFormat {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT)
simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
return simpleDateFormat
}
}

View file

@ -1,53 +0,0 @@
package fr.free.nrw.commons.utils;
import static android.text.format.DateFormat.getBestDateTimePattern;
import androidx.annotation.NonNull;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
public final class DateUtil {
private static Map<String, SimpleDateFormat> DATE_FORMATS = new HashMap<>();
// TODO: Switch to DateTimeFormatter when minSdk = 26.
public static synchronized String iso8601DateFormat(Date date) {
return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).format(date);
}
public static synchronized Date iso8601DateParse(String date) throws ParseException {
return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).parse(date);
}
public static String getMonthOnlyDateString(@NonNull Date date) {
return getDateStringWithSkeletonPattern(date, "MMMM d");
}
public static String getExtraShortDateString(@NonNull Date date) {
return getDateStringWithSkeletonPattern(date, "MMM d");
}
public static synchronized String getDateStringWithSkeletonPattern(@NonNull Date date, @NonNull String pattern) {
return getCachedDateFormat(getBestDateTimePattern(Locale.getDefault(), pattern), Locale.getDefault(), false).format(date);
}
private static SimpleDateFormat getCachedDateFormat(String pattern, Locale locale, boolean utc) {
if (!DATE_FORMATS.containsKey(pattern)) {
SimpleDateFormat df = new SimpleDateFormat(pattern, locale);
if (utc) {
df.setTimeZone(TimeZone.getTimeZone("UTC"));
}
DATE_FORMATS.put(pattern, df);
}
return DATE_FORMATS.get(pattern);
}
private DateUtil() {
}
}

View file

@ -0,0 +1,62 @@
package fr.free.nrw.commons.utils
import android.text.format.DateFormat.getBestDateTimePattern
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.HashMap
import java.util.Locale
import java.util.TimeZone
/**
* Utility class for date formatting and parsing.
* TODO: Switch to DateTimeFormatter when minSdk = 26.
*/
object DateUtil {
private val DATE_FORMATS: MutableMap<String, SimpleDateFormat> = HashMap()
@JvmStatic
@Synchronized
fun iso8601DateFormat(date: Date): String {
return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).format(date)
}
@JvmStatic
@Synchronized
@Throws(ParseException::class)
fun iso8601DateParse(date: String): Date {
return getCachedDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT, true).parse(date)
}
@JvmStatic
fun getMonthOnlyDateString(date: Date): String {
return getDateStringWithSkeletonPattern(date, "MMMM d")
}
@JvmStatic
fun getExtraShortDateString(date: Date): String {
return getDateStringWithSkeletonPattern(date, "MMM d")
}
@JvmStatic
@Synchronized
fun getDateStringWithSkeletonPattern(date: Date, pattern: String): String {
return getCachedDateFormat(
getBestDateTimePattern(Locale.getDefault(), pattern),
Locale.getDefault(), false
).format(date)
}
@JvmStatic
private fun getCachedDateFormat(pattern: String, locale: Locale, utc: Boolean): SimpleDateFormat {
if (!DATE_FORMATS.containsKey(pattern)) {
val df = SimpleDateFormat(pattern, locale)
if (utc) {
df.timeZone = TimeZone.getTimeZone("UTC")
}
DATE_FORMATS[pattern] = df
}
return DATE_FORMATS[pattern]!!
}
}

View file

@ -1,91 +0,0 @@
package fr.free.nrw.commons.utils;
import android.content.Context;
import android.os.Build;
import java.util.HashMap;
import java.util.Map;
import fr.free.nrw.commons.utils.model.ConnectionType;
import fr.free.nrw.commons.utils.model.NetworkConnectionType;
import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR;
import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR_3G;
import static fr.free.nrw.commons.utils.model.ConnectionType.CELLULAR_4G;
import static fr.free.nrw.commons.utils.model.ConnectionType.NO_INTERNET;
import static fr.free.nrw.commons.utils.model.ConnectionType.WIFI_NETWORK;
import static fr.free.nrw.commons.utils.model.NetworkConnectionType.FOUR_G;
import static fr.free.nrw.commons.utils.model.NetworkConnectionType.THREE_G;
import static fr.free.nrw.commons.utils.model.NetworkConnectionType.TWO_G;
import static fr.free.nrw.commons.utils.model.NetworkConnectionType.UNKNOWN;
import static fr.free.nrw.commons.utils.model.NetworkConnectionType.WIFI;
/**
* Util class to get any information about the user's device
* Ensure that any sensitive information like IMEI is not fetched/shared without user's consent
*/
public class DeviceInfoUtil {
private static final Map<NetworkConnectionType, ConnectionType> TYPE_MAPPING = new HashMap<>();
static {
TYPE_MAPPING.put(TWO_G, CELLULAR);
TYPE_MAPPING.put(THREE_G, CELLULAR_3G);
TYPE_MAPPING.put(FOUR_G, CELLULAR_4G);
TYPE_MAPPING.put(WIFI, WIFI_NETWORK);
TYPE_MAPPING.put(UNKNOWN, CELLULAR);
}
/**
* Get network connection type
* @param context
* @return wifi/cellular-4g/cellular-3g/cellular-2g/no-internet
*/
public static ConnectionType getConnectionType(Context context) {
if (!NetworkUtils.isInternetConnectionEstablished(context)) {
return NO_INTERNET;
}
NetworkConnectionType networkType = NetworkUtils.getNetworkType(context);
ConnectionType deviceNetworkType = TYPE_MAPPING.get(networkType);
return deviceNetworkType == null ? CELLULAR : deviceNetworkType;
}
/**
* Get Device manufacturer
* @return
*/
public static String getDeviceManufacturer() {
return Build.MANUFACTURER;
}
/**
* Get Device model name
* @return
*/
public static String getDeviceModel() {
return Build.MODEL;
}
/**
* Get Android version. Eg. 4.4.2
* @return
*/
public static String getAndroidVersion() {
return Build.VERSION.RELEASE;
}
/**
* Get API Level. Eg. 26
* @return
*/
public static String getAPILevel() {
return Build.VERSION.SDK;
}
/**
* Get Device.
* @return
*/
public static String getDevice() {
return Build.DEVICE;
}
}

View file

@ -0,0 +1,80 @@
package fr.free.nrw.commons.utils
import android.content.Context
import android.os.Build
import fr.free.nrw.commons.utils.model.ConnectionType
import fr.free.nrw.commons.utils.model.NetworkConnectionType
/**
* Util class to get any information about the user's device
* Ensure that any sensitive information like IMEI is not fetched/shared without user's consent
*/
object DeviceInfoUtil {
private val TYPE_MAPPING = mapOf(
NetworkConnectionType.TWO_G to ConnectionType.CELLULAR,
NetworkConnectionType.THREE_G to ConnectionType.CELLULAR_3G,
NetworkConnectionType.FOUR_G to ConnectionType.CELLULAR_4G,
NetworkConnectionType.WIFI to ConnectionType.WIFI_NETWORK,
NetworkConnectionType.UNKNOWN to ConnectionType.CELLULAR
)
/**
* Get network connection type
* @param context
* @return wifi/cellular-4g/cellular-3g/cellular-2g/no-internet
*/
@JvmStatic
fun getConnectionType(context: Context): ConnectionType {
return if (!NetworkUtils.isInternetConnectionEstablished(context)) {
ConnectionType.NO_INTERNET
} else {
val networkType = NetworkUtils.getNetworkType(context)
TYPE_MAPPING[networkType] ?: ConnectionType.CELLULAR
}
}
/**
* Get Device manufacturer
* @return
*/
@JvmStatic
fun getDeviceManufacturer(): String {
return Build.MANUFACTURER
}
/**
* Get Device model name
* @return
*/
@JvmStatic
fun getDeviceModel(): String {
return Build.MODEL
}
/**
* Get Android version. Eg. 4.4.2
* @return
*/
@JvmStatic
fun getAndroidVersion(): String {
return Build.VERSION.RELEASE
}
/**
* Get API Level. Eg. 26
* @return
*/
@JvmStatic
fun getAPILevel(): String {
return Build.VERSION.SDK
}
/**
* Get Device.
* @return
*/
@JvmStatic
fun getDevice(): String {
return Build.DEVICE
}
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.utils;
import android.os.Handler;
import android.os.Looper;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorUtils {
private static final Executor uiExecutor = command -> {
if (Looper.myLooper() == Looper.getMainLooper()) {
command.run();
} else {
new Handler(Looper.getMainLooper()).post(command);
}
};
public static Executor uiExecutor() {
return uiExecutor;
}
private static final ExecutorService executor = Executors.newFixedThreadPool(3);
public static ExecutorService get() {
return executor;
}
}

Some files were not shown because too many files have changed in this diff Show more