Convert upload to kotlin (part 3) (#6104)

* Convert UploadCategoriesFragment to kotlin

* Convert UploadBaseFragment to kotlin

* Convert UploadItem to kotlin

* Convert UploadModel to kotlin

* Convert UploadMediaDetailAdapter to kotlin

* Convert UploadActivity to kotlin

* Convert UploadMediaPresenter to kotlin

* Convert UploadMediaDetailFragment to kotlin

* Fix NPE that broke uploads
This commit is contained in:
Paul Hawke 2025-01-13 08:04:09 -06:00 committed by GitHub
parent 6d64357d45
commit 0e735512bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3236 additions and 3564 deletions

View file

@ -101,7 +101,7 @@ data class Contribution constructor(
*/
fun formatCaptions(uploadMediaDetails: List<UploadMediaDetail>) =
uploadMediaDetails
.associate { it.languageCode!! to it.captionText }
.associate { it.languageCode!! to it.captionText!! }
.filter { it.value.isNotBlank() }
/**
@ -112,7 +112,7 @@ data class Contribution constructor(
*/
fun formatDescriptions(descriptions: List<UploadMediaDetail>) =
descriptions
.filter { it.descriptionText.isNotEmpty() }
.filter { !it.descriptionText.isNullOrEmpty() }
.joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
}

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.description
import android.app.ProgressDialog
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.speech.RecognizerIntent
@ -72,7 +71,7 @@ class DescriptionEditActivity :
private lateinit var binding: ActivityDescriptionEditBinding
private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null
private var descriptionAndCaptions: MutableList<UploadMediaDetail>? = null
private val voiceInputResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@ -114,22 +113,18 @@ class DescriptionEditActivity :
* Initializes the RecyclerView
* @param descriptionAndCaptions list of description and caption
*/
private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) {
private fun initRecyclerView(descriptionAndCaptions: MutableList<UploadMediaDetail>?) {
uploadMediaDetailAdapter =
UploadMediaDetailAdapter(
this,
savedLanguageValue,
descriptionAndCaptions,
descriptionAndCaptions ?: mutableListOf(),
recentLanguagesDao,
voiceInputResultLauncher
)
uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int ->
showInfoAlert(
titleStringID,
messageStringId,
)
}
uploadMediaDetailAdapter.setEventListener(this)
uploadMediaDetailAdapter.callback = UploadMediaDetailAdapter.Callback(::showInfoAlert)
uploadMediaDetailAdapter.eventListener = this
rvDescriptions = binding.rvDescriptionsCaptions
rvDescriptions!!.layoutManager = LinearLayoutManager(this)
rvDescriptions!!.adapter = uploadMediaDetailAdapter
@ -272,11 +267,11 @@ class DescriptionEditActivity :
applicationContext,
media,
mediaDetail.languageCode!!,
mediaDetail.captionText,
mediaDetail.captionText!!,
).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { s: Boolean? ->
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!!
media.captions = updatedCaptions
Timber.d("Caption is added.")
},

View file

@ -41,8 +41,8 @@ import fr.free.nrw.commons.location.LocationPermissionsHelper
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_ZOOM
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL
import io.reactivex.android.schedulers.AndroidSchedulers

View file

@ -1569,7 +1569,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
mediaDetail: UploadMediaDetail,
updatedCaptions: MutableMap<String, String>
) {
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!!
media!!.captions = updatedCaptions
}

View file

@ -17,7 +17,7 @@ import java.util.HashMap
class RecentLanguagesAdapter constructor(
context: Context,
var recentLanguages: List<Language>,
private val selectedLanguages: HashMap<*, String>,
private val selectedLanguages: MutableMap<Int, String>,
) : ArrayAdapter<String?>(context, R.layout.row_item_languages_spinner) {
/**
* Selected language code in UploadMediaDetailAdapter

View file

@ -46,7 +46,7 @@ class UploadRepository @Inject constructor(
*
* @return
*/
fun buildContributions(): Observable<Contribution>? {
fun buildContributions(): Observable<Contribution> {
return uploadModel.buildContributions()
}
@ -69,7 +69,7 @@ class UploadRepository @Inject constructor(
* @return
*/
fun getUploads(): List<UploadItem> {
return uploadModel.getUploads()
return uploadModel.uploads
}
/**
@ -177,7 +177,7 @@ class UploadRepository @Inject constructor(
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): Observable<UploadItem>? {
): Observable<UploadItem> {
return uploadModel.preProcessImage(
uploadableFile,
place,
@ -193,7 +193,7 @@ class UploadRepository @Inject constructor(
* @param location Location of the image
* @return Quality of UploadItem
*/
fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int>? {
fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int> {
return uploadModel.getImageQuality(uploadItem, location)
}
@ -213,7 +213,7 @@ class UploadRepository @Inject constructor(
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
fun getCaptionQuality(uploadItem: UploadItem): Single<Int>? {
fun getCaptionQuality(uploadItem: UploadItem): Single<Int> {
return uploadModel.getCaptionQuality(uploadItem)
}
@ -275,7 +275,7 @@ class UploadRepository @Inject constructor(
* @param selectedExistingDepictions existing depicts
*/
fun setSelectedExistingDepictions(selectedExistingDepictions: List<String>) {
uploadModel.selectedExistingDepictions = selectedExistingDepictions
uploadModel.selectedExistingDepictions = selectedExistingDepictions.toMutableList()
}
/**

View file

@ -52,7 +52,7 @@ class FileProcessor
* Processes filePath coordinates, either from EXIF data or user location
*/
fun processFileCoordinates(
similarImageInterface: SimilarImageInterface,
similarImageInterface: SimilarImageInterface?,
filePath: String?,
inAppPictureLocation: LatLng?,
): ImageCoordinates {
@ -146,7 +146,7 @@ class FileProcessor
*/
private fun findOtherImages(
fileBeingProcessed: File,
similarImageInterface: SimilarImageInterface,
similarImageInterface: SimilarImageInterface?,
) {
val oneHundredAndTwentySeconds = 120 * 1000L
// Time when the original image was created
@ -161,7 +161,7 @@ class FileProcessor
.map { Pair(it, readImageCoordinates(it)) }
.firstOrNull { it.second?.decimalCoords != null }
?.let { fileCoordinatesPair ->
similarImageInterface.showSimilarImageFragment(
similarImageInterface?.showSimilarImageFragment(
fileBeingProcessed.path,
fileCoordinatesPair.first.absolutePath,
fileCoordinatesPair.second,

View file

@ -23,7 +23,7 @@ import java.util.Locale
*/
class LanguagesAdapter constructor(
context: Context,
private val selectedLanguages: HashMap<*, String>,
private val selectedLanguages: MutableMap<Int, String>,
) : ArrayAdapter<String?>(context, R.layout.row_item_languages_spinner) {
companion object {
/**

View file

@ -1,986 +0,0 @@
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.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;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationManager;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.CheckBox;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionController;
import fr.free.nrw.commons.databinding.ActivityUploadBinding;
import fr.free.nrw.commons.filepicker.Constants.RequestCodes;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.mwapi.UserClient;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadBaseFragment.Callback;
import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment;
import fr.free.nrw.commons.upload.depicts.DepictsFragment;
import fr.free.nrw.commons.upload.license.MediaLicenseFragment;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
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.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class UploadActivity extends BaseActivity implements
UploadContract.View, UploadBaseFragment.Callback, ThumbnailsAdapter.OnThumbnailDeletedListener {
@Inject
ContributionController contributionController;
@Inject
@Named("default_preferences")
JsonKvStore directKvStore;
@Inject
UploadContract.UserActionListener presenter;
@Inject
SessionManager sessionManager;
@Inject
UserClient userClient;
@Inject
LocationServiceManager locationManager;
private boolean isTitleExpanded = true;
private CompositeDisposable compositeDisposable;
private ProgressDialog progressDialog;
private UploadImageAdapter uploadImagesAdapter;
private List<UploadBaseFragment> fragments;
private UploadCategoriesFragment uploadCategoriesFragment;
private DepictsFragment depictsFragment;
private MediaLicenseFragment mediaLicenseFragment;
private ThumbnailsAdapter thumbnailsAdapter;
BasicKvStore store;
private Place place;
private LatLng prevLocation;
private LatLng currLocation;
private static boolean uploadIsOfAPlace = false;
private boolean isInAppCameraUpload;
private List<UploadableFile> uploadableFiles = Collections.emptyList();
private int currentSelectedPosition = 0;
/*
Checks for if multiple files selected
*/
private boolean isMultipleFilesSelected = false;
public static final String EXTRA_FILES = "commons_image_exta";
public static final String LOCATION_BEFORE_IMAGE_CAPTURE = "user_location_before_image_capture";
public static final String IN_APP_CAMERA_UPLOAD = "in_app_camera_upload";
/**
* Stores all nearby places found and related users response for
* each place while uploading media
*/
public static HashMap<Place,Boolean> nearbyPopupAnswers;
/**
* A private boolean variable to control whether a permissions dialog should be shown
* when necessary. Initially, it is set to `true`, indicating that the permissions dialog
* should be displayed if permissions are missing and it is first time calling
* `checkStoragePermissions` method.
* This variable is used in the `checkStoragePermissions` method to determine whether to
* show a permissions dialog to the user if the required permissions are not granted.
* If `showPermissionsDialog` is set to `true` and the necessary permissions are missing,
* a permissions dialog will be displayed to request the required permissions. If set
* to `false`, the dialog won't be shown.
*
* @see UploadActivity#checkStoragePermissions()
*/
private boolean showPermissionsDialog = true;
/**
* Whether fragments have been saved.
*/
private boolean isFragmentsSaved = false;
public static final String keyForCurrentUploadImagesSize = "CurrentUploadImagesSize";
public static final String storeNameForCurrentUploadImagesSize = "CurrentUploadImageQualities";
private ActivityUploadBinding binding;
@SuppressLint("CheckResult")
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityUploadBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
/*
If Configuration of device is changed then get the new fragments
created by the system and populate the fragments ArrayList
*/
if (savedInstanceState != null) {
isFragmentsSaved = true;
final List<Fragment> fragmentList = getSupportFragmentManager().getFragments();
fragments = new ArrayList<>();
for (final Fragment fragment : fragmentList) {
fragments.add((UploadBaseFragment) fragment);
}
}
compositeDisposable = new CompositeDisposable();
init();
binding.rlContainerTitle.setOnClickListener(v -> onRlContainerTitleClicked());
nearbyPopupAnswers = new HashMap<>();
//getting the current dpi of the device and if it is less than 320dp i.e. overlapping
//threshold, thumbnails automatically minimizes
final DisplayMetrics metrics = getResources().getDisplayMetrics();
final float dpi = (metrics.widthPixels)/(metrics.density);
if (dpi<=321) {
onRlContainerTitleClicked();
}
if (PermissionUtils.hasPermission(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
locationManager.registerLocationManager();
}
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER);
store = new BasicKvStore(this, storeNameForCurrentUploadImagesSize);
store.clearAll();
checkStoragePermissions();
}
private void init() {
initProgressDialog();
initViewPager();
initThumbnailsRecyclerView();
//And init other things you need to
}
private void initProgressDialog() {
progressDialog = new ProgressDialog(this);
progressDialog.setMessage(getString(R.string.please_wait));
progressDialog.setCancelable(false);
}
private void initThumbnailsRecyclerView() {
binding.rvThumbnails.setLayoutManager(new LinearLayoutManager(this,
LinearLayoutManager.HORIZONTAL, false));
thumbnailsAdapter = new ThumbnailsAdapter(() -> currentSelectedPosition);
thumbnailsAdapter.setOnThumbnailDeletedListener(this);
binding.rvThumbnails.setAdapter(thumbnailsAdapter);
}
private void initViewPager() {
uploadImagesAdapter = new UploadImageAdapter(getSupportFragmentManager());
binding.vpUpload.setAdapter(uploadImagesAdapter);
binding.vpUpload.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(final int position, final float positionOffset,
final int positionOffsetPixels) {
}
@Override
public void onPageSelected(final int position) {
currentSelectedPosition = position;
if (position >= uploadableFiles.size()) {
binding.cvContainerTopCard.setVisibility(View.GONE);
} else {
thumbnailsAdapter.notifyDataSetChanged();
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
}
}
@Override
public void onPageScrollStateChanged(final int state) {
}
});
}
@Override
public boolean isLoggedIn() {
return sessionManager.isUserLoggedIn();
}
@Override
protected void onResume() {
super.onResume();
presenter.onAttachView(this);
if (!isLoggedIn()) {
askUserToLogIn();
}
checkBlockStatus();
}
/**
* Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
* is created to notify the user
*/
protected void checkBlockStatus() {
compositeDisposable.add(userClient.isUserBlockedFromCommons()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.filter(result -> result)
.subscribe(result -> DialogUtil.showAlertDialog(
this,
getString(R.string.block_notification_title),
getString(R.string.block_notification),
getString(R.string.ok),
this::finish)));
}
public void checkStoragePermissions() {
// Check if all required permissions are granted
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
receiveSharedItems();
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
} else {
// Permissions are missing
binding.cvContainerTopCard.setVisibility(View.INVISIBLE);
if(showPermissionsDialog){
checkPermissionsAndPerformAction(this,
() -> {
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
this.receiveSharedItems();
},() -> {
this.showPermissionsDialog = true;
this.checkStoragePermissions();
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale_for_image_share,
getPERMISSIONS_STORAGE());
}
}
/* If all permissions are not granted and a dialog is already showing on screen
showPermissionsDialog will set to false making it not show dialog again onResume,
but if user Denies any permission showPermissionsDialog will be to true
and permissions dialog will be shown again.
*/
this.showPermissionsDialog = hasAllPermissions ;
}
@Override
protected void onStop() {
// Resetting setImageCancelled to false
setImageCancelled(false);
super.onStop();
}
@Override
public void returnToMainActivity() {
finish();
}
/**
* go to the uploadProgress activity to check the status of uploading
*/
@Override
public void goToUploadProgressActivity() {
startActivity(new Intent(this, UploadProgressActivity.class));
}
/**
* Show/Hide the progress dialog
*/
@Override
public void showProgress(final boolean shouldShow) {
if (shouldShow) {
if (!progressDialog.isShowing()) {
progressDialog.show();
}
} else {
if (progressDialog != null && !isFinishing()) {
progressDialog.dismiss();
}
}
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
@Override
public void showMessage(final int messageResourceId) {
ViewUtil.showLongToast(this, messageResourceId);
}
@Override
public List<UploadableFile> getUploadableFiles() {
return uploadableFiles;
}
@Override
public void showHideTopCard(final boolean shouldShow) {
binding.llContainerTopCard.setVisibility(shouldShow ? View.VISIBLE : View.GONE);
}
@Override
public void onUploadMediaDeleted(final int index) {
fragments.remove(index);//Remove the corresponding fragment
uploadableFiles.remove(index);//Remove the files from the list
thumbnailsAdapter.notifyItemRemoved(index); //Notify the thumbnails adapter
uploadImagesAdapter.notifyDataSetChanged(); //Notify the ViewPager
}
@Override
public void updateTopCardTitle() {
binding.tvTopCardTitle.setText(getResources()
.getQuantityString(R.plurals.upload_count_title, uploadableFiles.size(), uploadableFiles.size()));
}
@Override
public void makeUploadRequest() {
WorkRequestHelper.Companion.makeOneTimeWorkRequest(getApplicationContext(),
ExistingWorkPolicy.APPEND_OR_REPLACE);
}
@Override
public void askUserToLogIn() {
Timber.d("current session is null, asking user to login");
ViewUtil.showLongToast(this, getString(R.string.user_not_logged_in));
final Intent loginIntent = new Intent(UploadActivity.this, LoginActivity.class);
startActivity(loginIntent);
}
@Override
public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
boolean areAllGranted = false;
if (requestCode == RequestCodes.STORAGE) {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
for (int i = 0; i < grantResults.length; i++) {
final String permission = permissions[i];
areAllGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED;
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
final boolean showRationale = shouldShowRequestPermissionRationale(permission);
if (!showRationale) {
DialogUtil.showAlertDialog(this,
getString(R.string.storage_permissions_denied),
getString(R.string.unable_to_share_upload_item),
getString(android.R.string.ok),
this::finish);
} else {
DialogUtil.showAlertDialog(this,
getString(R.string.storage_permission_title),
getString(
R.string.write_storage_permission_rationale_for_image_share),
getString(android.R.string.ok),
this::checkStoragePermissions);
}
}
}
if (areAllGranted) {
receiveSharedItems();
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
/**
* Sets the flag indicating whether the upload is of a specific place.
*
* @param uploadOfAPlace a boolean value indicating whether the upload is of place.
*/
public static void setUploadIsOfAPlace(final boolean uploadOfAPlace) {
uploadIsOfAPlace = uploadOfAPlace;
}
private void receiveSharedItems() {
final Intent intent = getIntent();
final String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
receiveExternalSharedItems();
} else if (ACTION_INTERNAL_UPLOADS.equals(action)) {
receiveInternalSharedItems();
}
if (uploadableFiles == null || uploadableFiles.isEmpty()) {
handleNullMedia();
} else {
//Show thumbnails
if (uploadableFiles.size() > 1){
if(!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")){//If there is only file, no need to show the image thumbnails
showAlertDialogForCategories();
}
if (uploadableFiles.size() > 3 &&
!defaultKvStore.getBoolean("hasAlreadyLaunchedBigMultiupload")){
showAlertForBattery();
}
thumbnailsAdapter.setUploadableFiles(uploadableFiles);
} else {
binding.llContainerTopCard.setVisibility(View.GONE);
}
binding.tvTopCardTitle.setText(getResources()
.getQuantityString(R.plurals.upload_count_title, uploadableFiles.size(), uploadableFiles.size()));
if(fragments == null){
fragments = new ArrayList<>();
}
for (final UploadableFile uploadableFile : uploadableFiles) {
final UploadMediaDetailFragment uploadMediaDetailFragment = new UploadMediaDetailFragment();
if (!uploadIsOfAPlace) {
handleLocation();
uploadMediaDetailFragment.setImageToBeUploaded(uploadableFile, place, currLocation);
locationManager.unregisterLocationManager();
} else {
uploadMediaDetailFragment.setImageToBeUploaded(uploadableFile, place, currLocation);
}
final UploadMediaDetailFragmentCallback uploadMediaDetailFragmentCallback = new UploadMediaDetailFragmentCallback() {
@Override
public void deletePictureAtIndex(final int index) {
store.putInt(keyForCurrentUploadImagesSize,
(store.getInt(keyForCurrentUploadImagesSize) - 1));
presenter.deletePictureAtIndex(index);
}
/**
* Changes the thumbnail of an UploadableFile at the specified index.
* This method updates the list of uploadableFiles by replacing the UploadableFile
* at the given index with a new UploadableFile created from the provided file path.
* After updating the list, it notifies the RecyclerView's adapter to refresh its data,
* ensuring that the thumbnail change is reflected in the UI.
*
* @param index The index of the UploadableFile to be updated.
* @param filepath The file path of the new thumbnail image.
*/
@Override
public void changeThumbnail(final int index, final String filepath) {
uploadableFiles.remove(index);
uploadableFiles.add(index, new UploadableFile(new File(filepath)));
binding.rvThumbnails.getAdapter().notifyDataSetChanged();
}
@Override
public void onNextButtonClicked(final int index) {
UploadActivity.this.onNextButtonClicked(index);
}
@Override
public void onPreviousButtonClicked(final int index) {
UploadActivity.this.onPreviousButtonClicked(index);
}
@Override
public void showProgress(final boolean shouldShow) {
UploadActivity.this.showProgress(shouldShow);
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
};
if(isFragmentsSaved){
final UploadMediaDetailFragment fragment = (UploadMediaDetailFragment) fragments.get(0);
fragment.setCallback(uploadMediaDetailFragmentCallback);
}else{
uploadMediaDetailFragment.setCallback(uploadMediaDetailFragmentCallback);
fragments.add(uploadMediaDetailFragment);
}
}
//If fragments are not created, create them and add them to the fragments ArrayList
if(!isFragmentsSaved){
uploadCategoriesFragment = new UploadCategoriesFragment();
if (place != null) {
final Bundle categoryBundle = new Bundle();
categoryBundle.putString(SELECTED_NEARBY_PLACE_CATEGORY, place.getCategory());
uploadCategoriesFragment.setArguments(categoryBundle);
}
uploadCategoriesFragment.setCallback(this);
depictsFragment = new DepictsFragment();
final Bundle placeBundle = new Bundle();
placeBundle.putParcelable(SELECTED_NEARBY_PLACE, place);
depictsFragment.setArguments(placeBundle);
depictsFragment.setCallback(this);
mediaLicenseFragment = new MediaLicenseFragment();
mediaLicenseFragment.setCallback(this);
fragments.add(depictsFragment);
fragments.add(uploadCategoriesFragment);
fragments.add(mediaLicenseFragment);
}else{
for(int i=1;i<fragments.size();i++){
fragments.get(i).setCallback(new Callback() {
@Override
public void onNextButtonClicked(final int index) {
if (index < fragments.size() - 1) {
binding.vpUpload.setCurrentItem(index + 1, false);
fragments.get(index + 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 0) ? index-1 : 0, 0);
} else {
presenter.handleSubmit();
}
}
@Override
public void onPreviousButtonClicked(final int index) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true);
fragments.get(index - 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 3) ? index-2 : 0, 0);
}
}
@Override
public void showProgress(final boolean shouldShow) {
if (shouldShow) {
if (!progressDialog.isShowing()) {
progressDialog.show();
}
} else {
if (progressDialog != null && !isFinishing()) {
progressDialog.dismiss();
}
}
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
});
}
}
uploadImagesAdapter.setFragments(fragments);
binding.vpUpload.setOffscreenPageLimit(fragments.size());
}
// Saving size of uploadableFiles
store.putInt(keyForCurrentUploadImagesSize, uploadableFiles.size());
}
/**
* Users may uncheck Location tag from the Manage EXIF tags setting any time.
* So, their location must not be shared in this case.
*
*/
private boolean isLocationTagUncheckedInTheSettings() {
final Set<String> prefExifTags = defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS);
if (prefExifTags.contains(getString(R.string.exif_tag_location))) {
return false;
}
return true;
}
/**
* Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail.
* Fixes: <a href="https://github.com/commons-app/apps-android-commons/issues/5511">Issue</a>
*
* @param index Index of image to be removed
* @param maxSize Max size of the {@code uploadableFiles}
*/
@Override
public void highlightNextImageOnCancelledImage(final int index, final int maxSize) {
if (binding.vpUpload != null && index < (maxSize)) {
binding.vpUpload.setCurrentItem(index + 1, false);
binding.vpUpload.setCurrentItem(index, false);
}
}
/**
* Used to check if user has cancelled upload of any image in current upload
* so that location compare doesn't show up again in same upload.
* Fixes: <a href="https://github.com/commons-app/apps-android-commons/issues/5511">Issue</a>
*
* @param isCancelled Is true when user has cancelled upload of any image in current upload
*/
@Override
public void setImageCancelled(final boolean isCancelled) {
final BasicKvStore basicKvStore = new BasicKvStore(this,"IsAnyImageCancelled");
basicKvStore.putBoolean("IsAnyImageCancelled", isCancelled);
}
/**
* Calculate the difference between current location and
* location recorded before capturing the image
*
*/
private float getLocationDifference(final LatLng currLocation, final LatLng prevLocation) {
if (prevLocation == null) {
return 0.0f;
}
final float[] distance = new float[2];
Location.distanceBetween(
currLocation.getLatitude(), currLocation.getLongitude(),
prevLocation.getLatitude(), prevLocation.getLongitude(), distance);
return distance[0];
}
private void receiveExternalSharedItems() {
uploadableFiles = contributionController.handleExternalImagesPicked(this, getIntent());
}
private void receiveInternalSharedItems() {
final Intent intent = getIntent();
Timber.d("Received intent %s with action %s", intent.toString(), intent.getAction());
uploadableFiles = intent.getParcelableArrayListExtra(EXTRA_FILES);
isMultipleFilesSelected = uploadableFiles.size() > 1;
Timber.i("Received multiple upload %s", uploadableFiles.size());
place = intent.getParcelableExtra(PLACE_OBJECT);
prevLocation = intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE);
isInAppCameraUpload = intent.getBooleanExtra(IN_APP_CAMERA_UPLOAD, false);
resetDirectPrefs();
}
/**
* Returns if multiple files selected or not.
*/
public boolean getIsMultipleFilesSelected() {
return isMultipleFilesSelected;
}
public void resetDirectPrefs() {
directKvStore.remove(PLACE_OBJECT);
}
/**
* Handle null URI from the received intent.
* Current implementation will simply show a toast and finish the upload activity.
*/
private void handleNullMedia() {
ViewUtil.showLongToast(this, R.string.error_processing_image);
finish();
}
@Override
public void showAlertDialog(final int messageResourceId, @NonNull final Runnable onPositiveClick) {
DialogUtil.showAlertDialog(this,
"",
getString(messageResourceId),
getString(R.string.ok),
onPositiveClick);
}
@Override
public void onNextButtonClicked(final int index) {
if (index < fragments.size() - 1) {
binding.vpUpload.setCurrentItem(index + 1, false);
fragments.get(index + 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 0) ? index - 1 : 0, 0);
if (index < fragments.size() - 4) {
// check image quality if next image exists
presenter.checkImageQuality(index + 1);
}
} else {
presenter.handleSubmit();
}
}
@Override
public void onPreviousButtonClicked(final int index) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true);
fragments.get(index - 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 3) ? index-2 : 0, 0);
if ((index != 1) && ((index - 1) < uploadableFiles.size())) {
// Shows the top card if it was hidden because of the last image being deleted and
// now the user has hit previous button to go back to the media details
showHideTopCard(true);
}
}
}
@Override
public void onThumbnailDeleted(final int position) {
presenter.deletePictureAtIndex(position);
}
/**
* The adapter used to show image upload intermediate fragments
*/
private static class UploadImageAdapter extends FragmentStatePagerAdapter {
List<UploadBaseFragment> fragments;
public UploadImageAdapter(final FragmentManager fragmentManager) {
super(fragmentManager);
this.fragments = new ArrayList<>();
}
public void setFragments(final List<UploadBaseFragment> fragments) {
this.fragments = fragments;
notifyDataSetChanged();
}
@NonNull
@Override
public Fragment getItem(final int position) {
return fragments.get(position);
}
@Override
public int getCount() {
return fragments.size();
}
@Override
public int getItemPosition(@NonNull final Object item) {
return PagerAdapter.POSITION_NONE;
}
}
public void onRlContainerTitleClicked() {
binding.rvThumbnails.setVisibility(isTitleExpanded ? View.GONE : View.VISIBLE);
isTitleExpanded = !isTitleExpanded;
binding.ibToggleTopCard.setRotation(binding.ibToggleTopCard.getRotation() + 180);
}
@Override
protected void onDestroy() {
super.onDestroy();
// Resetting all values in store by clearing them
store.clearAll();
presenter.onDetachView();
compositeDisposable.clear();
fragments = null;
uploadImagesAdapter = null;
if (mediaLicenseFragment != null) {
mediaLicenseFragment.setCallback(null);
}
if (uploadCategoriesFragment != null) {
uploadCategoriesFragment.setCallback(null);
}
}
/**
* Get the value of the showPermissionDialog variable.
*
* @return {@code true} if Permission Dialog should be shown, {@code false} otherwise.
*/
public boolean isShowPermissionsDialog() {
return showPermissionsDialog;
}
/**
* Set the value of the showPermissionDialog variable.
*
* @param showPermissionsDialog {@code true} to indicate to show
* Permissions Dialog if permissions are missing, {@code false} otherwise.
*/
public void setShowPermissionsDialog(final boolean showPermissionsDialog) {
this.showPermissionsDialog = showPermissionsDialog;
}
/**
* Overrides the back button to make sure the user is prepared to lose their progress
*/
@Override
public void onBackPressed() {
DialogUtil.showAlertDialog(this,
getString(R.string.back_button_warning),
getString(R.string.back_button_warning_desc),
getString(R.string.back_button_continue),
getString(R.string.back_button_warning),
null,
this::finish
);
}
/**
* If the user uploads more than 1 file informs that
* depictions/categories apply to all pictures of a multi upload.
* This method takes no arguments and does not return any value.
* It shows the AlertDialog and continues the flow of uploads.
*/
private void showAlertDialogForCategories() {
UploadMediaPresenter.isCategoriesDialogShowing = true;
// Inflate the custom layout
final LayoutInflater inflater = getLayoutInflater();
final View view = inflater.inflate(R.layout.activity_upload_categories_dialog, null);
final CheckBox checkBox = view.findViewById(R.id.categories_checkbox);
// Create the alert dialog
final AlertDialog alertDialog = new AlertDialog.Builder(this)
.setView(view)
.setTitle(getString(R.string.multiple_files_depiction_header))
.setMessage(getString(R.string.multiple_files_depiction))
.setCancelable(false)
.setPositiveButton("OK", (dialog, which) -> {
if (checkBox.isChecked()) {
// Save the user's choice to not show the dialog again
defaultKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", true);
}
presenter.checkImageQuality(0);
UploadMediaPresenter.isCategoriesDialogShowing = false;
})
.setNegativeButton("", null)
.create();
alertDialog.show();
}
/** Suggest users to turn battery optimisation off when uploading
* more than a few files. That's because we have noticed that
* many-files uploads have a much higher probability of failing
* than uploads with less files. Show the dialog for Android 6
* and above as the ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
* intent was added in API level 23
*/
private void showAlertForBattery(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// When battery-optimisation dialog is shown don't show the image quality dialog
UploadMediaPresenter.isBatteryDialogShowing = true;
DialogUtil.showAlertDialog(
this,
getString(R.string.unrestricted_battery_mode),
getString(R.string.suggest_unrestricted_mode),
getString(R.string.title_activity_settings),
getString(R.string.cancel),
() -> {
/* Since opening the right settings page might be device dependent, using
https://github.com/WaseemSabir/BatteryPermissionHelper
directly appeared like a promising idea.
However, this simply closed the popup and did not make
the settings page appear on a Pixel as well as a Xiaomi device.
Used the standard intent instead of using this library as
it shows a list of all the apps on the device and allows users to
turn battery optimisation off.
*/
final Intent batteryOptimisationSettingsIntent = new Intent(
Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
startActivity(batteryOptimisationSettingsIntent);
// calling checkImageQuality after battery dialog is interacted with
// so that 2 dialogs do not pop up simultaneously
UploadMediaPresenter.isBatteryDialogShowing = false;
},
() -> {
UploadMediaPresenter.isBatteryDialogShowing = false;
}
);
defaultKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", true);
}
}
/**
* If the permission for Location is turned on and certain
* conditions are met, returns current location of the user.
*/
private void handleLocation(){
final LocationPermissionsHelper locationPermissionsHelper = new LocationPermissionsHelper(
this, locationManager, null);
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
currLocation = locationManager.getLastLocation();
}
if (currLocation != null) {
final float locationDifference = getLocationDifference(currLocation, prevLocation);
final boolean isLocationTagUnchecked = isLocationTagUncheckedInTheSettings();
/* Remove location if the user has unchecked the Location EXIF tag in the
Manage EXIF Tags setting or turned "Record location for in-app shots" off.
Also, location information is discarded if the difference between
current location and location recorded just before capturing the image
is greater than 100 meters */
if (isLocationTagUnchecked || locationDifference > 100
|| !defaultKvStore.getBoolean("inAppCameraLocationPref")
|| !isInAppCameraUpload) {
currLocation = null;
}
}
}
}

View file

@ -0,0 +1,947 @@
package fr.free.nrw.commons.upload
import android.Manifest
import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.widget.CheckBox
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager.widget.ViewPager.OnPageChangeListener
import androidx.work.ExistingWorkPolicy
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.databinding.ActivityUploadBinding
import fr.free.nrw.commons.filepicker.Constants.RequestCodes
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.location.LocationPermissionsHelper
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.mwapi.UserClient
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.ThumbnailsAdapter.OnThumbnailDeletedListener
import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment
import fr.free.nrw.commons.upload.depicts.DepictsFragment
import fr.free.nrw.commons.upload.license.MediaLicenseFragment
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter
import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE
import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction
import fr.free.nrw.commons.utils.PermissionUtils.hasPartialAccess
import fr.free.nrw.commons.utils.PermissionUtils.hasPermission
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Named
class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.Callback,
OnThumbnailDeletedListener {
@JvmField
@Inject
var contributionController: ContributionController? = null
@JvmField
@Inject
@field:Named("default_preferences")
var directKvStore: JsonKvStore? = null
@JvmField
@Inject
var presenter: UploadContract.UserActionListener? = null
@JvmField
@Inject
var sessionManager: SessionManager? = null
@JvmField
@Inject
var userClient: UserClient? = null
@JvmField
@Inject
var locationManager: LocationServiceManager? = null
private var isTitleExpanded = true
private var progressDialog: ProgressDialog? = null
private var uploadImagesAdapter: UploadImageAdapter? = null
private var fragments: MutableList<UploadBaseFragment>? = null
private var uploadCategoriesFragment: UploadCategoriesFragment? = null
private var depictsFragment: DepictsFragment? = null
private var mediaLicenseFragment: MediaLicenseFragment? = null
private var thumbnailsAdapter: ThumbnailsAdapter? = null
var store: BasicKvStore? = null
private var place: Place? = null
private var prevLocation: LatLng? = null
private var currLocation: LatLng? = null
private var isInAppCameraUpload = false
private var uploadableFiles: MutableList<UploadableFile> = mutableListOf()
private var currentSelectedPosition = 0
/**
* Returns if multiple files selected or not.
*/
/*
Checks for if multiple files selected
*/
var isMultipleFilesSelected: Boolean = false
private set
/**
* Get the value of the showPermissionDialog variable.
*
* @return `true` if Permission Dialog should be shown, `false` otherwise.
*/
/**
* Set the value of the showPermissionDialog variable.
*
* @param showPermissionsDialog `true` to indicate to show
* Permissions Dialog if permissions are missing, `false` otherwise.
*/
/**
* A private boolean variable to control whether a permissions dialog should be shown
* when necessary. Initially, it is set to `true`, indicating that the permissions dialog
* should be displayed if permissions are missing and it is first time calling
* `checkStoragePermissions` method.
* This variable is used in the `checkStoragePermissions` method to determine whether to
* show a permissions dialog to the user if the required permissions are not granted.
* If `showPermissionsDialog` is set to `true` and the necessary permissions are missing,
* a permissions dialog will be displayed to request the required permissions. If set
* to `false`, the dialog won't be shown.
*
* @see UploadActivity.checkStoragePermissions
*/
var isShowPermissionsDialog: Boolean = true
/**
* Whether fragments have been saved.
*/
private var isFragmentsSaved = false
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
/**
* Users may uncheck Location tag from the Manage EXIF tags setting any time.
* So, their location must not be shared in this case.
*
*/
private val isLocationTagUncheckedInTheSettings: Boolean
get() {
val prefExifTags: Set<String> =
defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS)
return !prefExifTags.contains(getString(R.string.exif_tag_location))
}
private var _binding: ActivityUploadBinding? = null
private val binding: ActivityUploadBinding get() = _binding!!
@SuppressLint("CheckResult")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = ActivityUploadBinding.inflate(layoutInflater)
setContentView(binding.root)
/*
If Configuration of device is changed then get the new fragments
created by the system and populate the fragments ArrayList
*/
if (savedInstanceState != null) {
isFragmentsSaved = true
fragments = mutableListOf<UploadBaseFragment>().apply {
supportFragmentManager.fragments.forEach { fragment ->
add(fragment as UploadBaseFragment)
}
}
}
init()
binding.rlContainerTitle.setOnClickListener { v: View? -> onRlContainerTitleClicked() }
nearbyPopupAnswers = mutableMapOf()
//getting the current dpi of the device and if it is less than 320dp i.e. overlapping
//threshold, thumbnails automatically minimizes
val metrics = resources.displayMetrics
val dpi = (metrics.widthPixels) / (metrics.density)
if (dpi <= 321) {
onRlContainerTitleClicked()
}
if (hasPermission(this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
locationManager!!.registerLocationManager()
}
locationManager!!.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER)
locationManager!!.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER)
store = BasicKvStore(this, storeNameForCurrentUploadImagesSize).apply {
clearAll()
}
checkStoragePermissions()
}
private fun init() {
initProgressDialog()
initViewPager()
initThumbnailsRecyclerView()
//And init other things you need to
}
private fun initProgressDialog() {
progressDialog = ProgressDialog(this)
progressDialog!!.setMessage(getString(R.string.please_wait))
progressDialog!!.setCancelable(false)
}
private fun initThumbnailsRecyclerView() {
binding.rvThumbnails.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL, false
)
thumbnailsAdapter = ThumbnailsAdapter { currentSelectedPosition }
thumbnailsAdapter!!.onThumbnailDeletedListener = this
binding.rvThumbnails.adapter = thumbnailsAdapter
}
private fun initViewPager() {
uploadImagesAdapter = UploadImageAdapter(supportFragmentManager)
binding.vpUpload.adapter = uploadImagesAdapter
binding.vpUpload.addOnPageChangeListener(object : OnPageChangeListener {
override fun onPageScrolled(
position: Int, positionOffset: Float,
positionOffsetPixels: Int
) = Unit
override fun onPageSelected(position: Int) {
currentSelectedPosition = position
if (position >= uploadableFiles!!.size) {
binding.cvContainerTopCard.visibility = View.GONE
} else {
thumbnailsAdapter!!.notifyDataSetChanged()
binding.cvContainerTopCard.visibility = View.VISIBLE
}
}
override fun onPageScrollStateChanged(state: Int) = Unit
})
}
override fun isLoggedIn(): Boolean = sessionManager!!.isUserLoggedIn
override fun onResume() {
super.onResume()
presenter!!.onAttachView(this)
if (!isLoggedIn()) {
askUserToLogIn()
}
checkBlockStatus()
}
/**
* Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
* is created to notify the user
*/
protected fun checkBlockStatus() {
compositeDisposable.add(
userClient!!.isUserBlockedFromCommons()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.filter { result: Boolean? -> result!! }
.subscribe { result: Boolean? ->
showAlertDialog(
this,
getString(R.string.block_notification_title),
getString(R.string.block_notification),
getString(R.string.ok)
) { finish() }
})
}
fun checkStoragePermissions() {
// Check if all required permissions are granted
val hasAllPermissions = hasPermission(this, PERMISSIONS_STORAGE)
val hasPartialAccess = hasPartialAccess(this)
if (hasAllPermissions || hasPartialAccess) {
// All required permissions are granted, so enable UI elements and perform actions
receiveSharedItems()
binding.cvContainerTopCard.visibility = View.VISIBLE
} else {
// Permissions are missing
binding.cvContainerTopCard.visibility = View.INVISIBLE
if (isShowPermissionsDialog) {
checkPermissionsAndPerformAction(
this,
Runnable {
binding.cvContainerTopCard.visibility = View.VISIBLE
receiveSharedItems()
}, Runnable {
isShowPermissionsDialog = true
checkStoragePermissions()
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale_for_image_share,
*PERMISSIONS_STORAGE
)
}
}
/* If all permissions are not granted and a dialog is already showing on screen
showPermissionsDialog will set to false making it not show dialog again onResume,
but if user Denies any permission showPermissionsDialog will be to true
and permissions dialog will be shown again.
*/
isShowPermissionsDialog = hasAllPermissions
}
override fun onStop() {
// Resetting setImageCancelled to false
setImageCancelled(false)
super.onStop()
}
override fun returnToMainActivity() = finish()
/**
* go to the uploadProgress activity to check the status of uploading
*/
override fun goToUploadProgressActivity() =
startActivity(Intent(this, UploadProgressActivity::class.java))
/**
* Show/Hide the progress dialog
*/
override fun showProgress(shouldShow: Boolean) {
if (shouldShow) {
if (!progressDialog!!.isShowing) {
progressDialog!!.show()
}
} else {
if (progressDialog != null && !isFinishing) {
progressDialog!!.dismiss()
}
}
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int =
fragments!!.indexOf(fragment)
override fun showMessage(messageResourceId: Int) {
showLongToast(this, messageResourceId)
}
override fun getUploadableFiles(): List<UploadableFile>? {
return uploadableFiles
}
override fun showHideTopCard(shouldShow: Boolean) {
binding.llContainerTopCard.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
override fun onUploadMediaDeleted(index: Int) {
fragments!!.removeAt(index) //Remove the corresponding fragment
uploadableFiles.removeAt(index) //Remove the files from the list
thumbnailsAdapter!!.notifyItemRemoved(index) //Notify the thumbnails adapter
uploadImagesAdapter!!.notifyDataSetChanged() //Notify the ViewPager
}
override fun updateTopCardTitle() {
binding.tvTopCardTitle.text = resources
.getQuantityString(
R.plurals.upload_count_title,
uploadableFiles!!.size,
uploadableFiles!!.size
)
}
override fun makeUploadRequest() {
makeOneTimeWorkRequest(
applicationContext,
ExistingWorkPolicy.APPEND_OR_REPLACE
)
}
override fun askUserToLogIn() {
Timber.d("current session is null, asking user to login")
showLongToast(this, getString(R.string.user_not_logged_in))
val loginIntent = Intent(this@UploadActivity, LoginActivity::class.java)
startActivity(loginIntent)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
var areAllGranted = false
if (requestCode == RequestCodes.STORAGE) {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
for (i in grantResults.indices) {
val permission = permissions[i]
areAllGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
val showRationale = shouldShowRequestPermissionRationale(permission)
if (!showRationale) {
showAlertDialog(
this,
getString(R.string.storage_permissions_denied),
getString(R.string.unable_to_share_upload_item),
getString(android.R.string.ok)
) { finish() }
} else {
showAlertDialog(
this,
getString(R.string.storage_permission_title),
getString(
R.string.write_storage_permission_rationale_for_image_share
),
getString(android.R.string.ok)
) { checkStoragePermissions() }
}
}
}
if (areAllGranted) {
receiveSharedItems()
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun receiveSharedItems() {
val intent = intent
val action = intent.action
if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) {
receiveExternalSharedItems()
} else if (ContributionController.ACTION_INTERNAL_UPLOADS == action) {
receiveInternalSharedItems()
}
if (uploadableFiles == null || uploadableFiles!!.isEmpty()) {
handleNullMedia()
} else {
//Show thumbnails
if (uploadableFiles!!.size > 1) {
if (!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")) { //If there is only file, no need to show the image thumbnails
showAlertDialogForCategories()
}
if (uploadableFiles!!.size > 3 &&
!defaultKvStore.getBoolean("hasAlreadyLaunchedBigMultiupload")
) {
showAlertForBattery()
}
thumbnailsAdapter!!.uploadableFiles = uploadableFiles
} else {
binding.llContainerTopCard.visibility = View.GONE
}
binding.tvTopCardTitle.text = resources
.getQuantityString(
R.plurals.upload_count_title,
uploadableFiles!!.size,
uploadableFiles!!.size
)
if (fragments == null) {
fragments = mutableListOf()
}
for (uploadableFile in uploadableFiles!!) {
val uploadMediaDetailFragment = UploadMediaDetailFragment()
if (!uploadIsOfAPlace) {
handleLocation()
uploadMediaDetailFragment.setImageToBeUploaded(
uploadableFile,
place,
currLocation
)
locationManager!!.unregisterLocationManager()
} else {
uploadMediaDetailFragment.setImageToBeUploaded(
uploadableFile,
place,
currLocation
)
}
val uploadMediaDetailFragmentCallback: UploadMediaDetailFragmentCallback =
object : UploadMediaDetailFragmentCallback {
override fun deletePictureAtIndex(index: Int) {
store!!.putInt(
keyForCurrentUploadImagesSize,
(store!!.getInt(keyForCurrentUploadImagesSize) - 1)
)
presenter!!.deletePictureAtIndex(index)
}
/**
* Changes the thumbnail of an UploadableFile at the specified index.
* This method updates the list of uploadableFiles by replacing the UploadableFile
* at the given index with a new UploadableFile created from the provided file path.
* After updating the list, it notifies the RecyclerView's adapter to refresh its data,
* ensuring that the thumbnail change is reflected in the UI.
*
* @param index The index of the UploadableFile to be updated.
* @param uri The file path of the new thumbnail image.
*/
override fun changeThumbnail(index: Int, uri: String) {
uploadableFiles.removeAt(index)
uploadableFiles.add(index, UploadableFile(File(uri)))
binding.rvThumbnails.adapter!!.notifyDataSetChanged()
}
override fun onNextButtonClicked(index: Int) {
this@UploadActivity.onNextButtonClicked(index)
}
override fun onPreviousButtonClicked(index: Int) {
this@UploadActivity.onPreviousButtonClicked(index)
}
override fun showProgress(shouldShow: Boolean) {
this@UploadActivity.showProgress(shouldShow)
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int {
return fragments!!.indexOf(fragment)
}
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
}
if (isFragmentsSaved) {
val fragment = fragments!![0] as UploadMediaDetailFragment?
fragment!!.fragmentCallback = uploadMediaDetailFragmentCallback
} else {
uploadMediaDetailFragment.fragmentCallback = uploadMediaDetailFragmentCallback
fragments!!.add(uploadMediaDetailFragment)
}
}
//If fragments are not created, create them and add them to the fragments ArrayList
if (!isFragmentsSaved) {
uploadCategoriesFragment = UploadCategoriesFragment()
if (place != null) {
val categoryBundle = Bundle()
categoryBundle.putString(SELECTED_NEARBY_PLACE_CATEGORY, place!!.category)
uploadCategoriesFragment!!.arguments = categoryBundle
}
uploadCategoriesFragment!!.callback = this
depictsFragment = DepictsFragment()
val placeBundle = Bundle()
placeBundle.putParcelable(SELECTED_NEARBY_PLACE, place)
depictsFragment!!.arguments = placeBundle
depictsFragment!!.callback = this
mediaLicenseFragment = MediaLicenseFragment()
mediaLicenseFragment!!.callback = this
fragments!!.add(depictsFragment!!)
fragments!!.add(uploadCategoriesFragment!!)
fragments!!.add(mediaLicenseFragment!!)
} else {
for (i in 1 until fragments!!.size) {
fragments!![i]!!.callback = object : UploadBaseFragment.Callback {
override fun onNextButtonClicked(index: Int) {
if (index < fragments!!.size - 1) {
binding.vpUpload.setCurrentItem(index + 1, false)
fragments!![index + 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(
if ((index > 0)) index - 1 else 0,
0
)
} else {
presenter!!.handleSubmit()
}
}
override fun onPreviousButtonClicked(index: Int) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true)
fragments!![index - 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(
if ((index > 3)) index - 2 else 0,
0
)
}
}
override fun showProgress(shouldShow: Boolean) {
if (shouldShow) {
if (!progressDialog!!.isShowing) {
progressDialog!!.show()
}
} else {
if (progressDialog != null && !isFinishing) {
progressDialog!!.dismiss()
}
}
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int {
return fragments!!.indexOf(fragment)
}
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
}
}
}
uploadImagesAdapter!!.fragments = fragments!!
binding.vpUpload.offscreenPageLimit = fragments!!.size
}
// Saving size of uploadableFiles
store!!.putInt(keyForCurrentUploadImagesSize, uploadableFiles!!.size)
}
/**
* Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail.
* Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511)
*
* @param index Index of image to be removed
* @param maxSize Max size of the `uploadableFiles`
*/
override fun highlightNextImageOnCancelledImage(index: Int, maxSize: Int) {
if (index < maxSize) {
binding.vpUpload.setCurrentItem(index + 1, false)
binding.vpUpload.setCurrentItem(index, false)
}
}
/**
* Used to check if user has cancelled upload of any image in current upload
* so that location compare doesn't show up again in same upload.
* Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511)
*
* @param isCancelled Is true when user has cancelled upload of any image in current upload
*/
override fun setImageCancelled(isCancelled: Boolean) {
val basicKvStore = BasicKvStore(this, "IsAnyImageCancelled")
basicKvStore.putBoolean("IsAnyImageCancelled", isCancelled)
}
/**
* Calculate the difference between current location and
* location recorded before capturing the image
*
*/
private fun getLocationDifference(currLocation: LatLng, prevLocation: LatLng?): Float {
if (prevLocation == null) {
return 0.0f
}
val distance = FloatArray(2)
Location.distanceBetween(
currLocation.latitude, currLocation.longitude,
prevLocation.latitude, prevLocation.longitude, distance
)
return distance[0]
}
private fun receiveExternalSharedItems() {
uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent)
}
private fun receiveInternalSharedItems() {
val intent = intent
Timber.d("Received intent %s with action %s", intent.toString(), intent.action)
uploadableFiles = mutableListOf<UploadableFile>().apply {
addAll(intent.getParcelableArrayListExtra(EXTRA_FILES) ?: emptyList())
}
isMultipleFilesSelected = uploadableFiles!!.size > 1
Timber.i("Received multiple upload %s", uploadableFiles!!.size)
place = intent.getParcelableExtra<Place>(PLACE_OBJECT)
prevLocation = intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE)
isInAppCameraUpload = intent.getBooleanExtra(IN_APP_CAMERA_UPLOAD, false)
resetDirectPrefs()
}
fun resetDirectPrefs() = directKvStore!!.remove(PLACE_OBJECT)
/**
* Handle null URI from the received intent.
* Current implementation will simply show a toast and finish the upload activity.
*/
private fun handleNullMedia() {
showLongToast(this, R.string.error_processing_image)
finish()
}
override fun showAlertDialog(messageResourceId: Int, onPositiveClick: Runnable) {
showAlertDialog(
this,
"",
getString(messageResourceId),
getString(R.string.ok),
onPositiveClick
)
}
override fun onNextButtonClicked(index: Int) {
if (index < fragments!!.size - 1) {
binding.vpUpload.setCurrentItem(index + 1, false)
fragments!![index + 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(if ((index > 0)) index - 1 else 0, 0)
if (index < fragments!!.size - 4) {
// check image quality if next image exists
presenter!!.checkImageQuality(index + 1)
}
} else {
presenter!!.handleSubmit()
}
}
override fun onPreviousButtonClicked(index: Int) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true)
fragments!![index - 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(if ((index > 3)) index - 2 else 0, 0)
if ((index != 1) && ((index - 1) < uploadableFiles!!.size)) {
// Shows the top card if it was hidden because of the last image being deleted and
// now the user has hit previous button to go back to the media details
showHideTopCard(true)
}
}
}
override fun onThumbnailDeleted(position: Int) = presenter!!.deletePictureAtIndex(position)
/**
* The adapter used to show image upload intermediate fragments
*/
private class UploadImageAdapter(fragmentManager: FragmentManager) :
FragmentStatePagerAdapter(fragmentManager) {
var fragments: List<UploadBaseFragment> = mutableListOf()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItem(position: Int): Fragment {
return fragments[position]
}
override fun getCount(): Int {
return fragments.size
}
override fun getItemPosition(item: Any): Int {
return POSITION_NONE
}
}
fun onRlContainerTitleClicked() {
binding.rvThumbnails.visibility =
if (isTitleExpanded) View.GONE else View.VISIBLE
isTitleExpanded = !isTitleExpanded
binding.ibToggleTopCard.rotation = binding.ibToggleTopCard.rotation + 180
}
override fun onDestroy() {
super.onDestroy()
// Resetting all values in store by clearing them
store!!.clearAll()
presenter!!.onDetachView()
compositeDisposable.clear()
fragments = null
uploadImagesAdapter = null
if (mediaLicenseFragment != null) {
mediaLicenseFragment!!.callback = null
}
if (uploadCategoriesFragment != null) {
uploadCategoriesFragment!!.callback = null
}
}
/**
* Overrides the back button to make sure the user is prepared to lose their progress
*/
override fun onBackPressed() {
showAlertDialog(
this,
getString(R.string.back_button_warning),
getString(R.string.back_button_warning_desc),
getString(R.string.back_button_continue),
getString(R.string.back_button_warning),
null
) { finish() }
}
/**
* If the user uploads more than 1 file informs that
* depictions/categories apply to all pictures of a multi upload.
* This method takes no arguments and does not return any value.
* It shows the AlertDialog and continues the flow of uploads.
*/
private fun showAlertDialogForCategories() {
UploadMediaPresenter.isCategoriesDialogShowing = true
// Inflate the custom layout
val inflater = layoutInflater
val view = inflater.inflate(R.layout.activity_upload_categories_dialog, null)
val checkBox = view.findViewById<CheckBox>(R.id.categories_checkbox)
// Create the alert dialog
val alertDialog = AlertDialog.Builder(this)
.setView(view)
.setTitle(getString(R.string.multiple_files_depiction_header))
.setMessage(getString(R.string.multiple_files_depiction))
.setPositiveButton("OK") { dialog: DialogInterface?, which: Int ->
if (checkBox.isChecked) {
// Save the user's choice to not show the dialog again
defaultKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", true)
}
presenter!!.setupBasicKvStoreFactory { BasicKvStore(this@UploadActivity, it) }
presenter!!.checkImageQuality(0)
UploadMediaPresenter.isCategoriesDialogShowing = false
}
.setNegativeButton("", null)
.create()
alertDialog.show()
}
/** Suggest users to turn battery optimisation off when uploading
* more than a few files. That's because we have noticed that
* many-files uploads have a much higher probability of failing
* than uploads with less files. Show the dialog for Android 6
* and above as the ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
* intent was added in API level 23
*/
private fun showAlertForBattery() {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
// When battery-optimisation dialog is shown don't show the image quality dialog
UploadMediaPresenter.isBatteryDialogShowing = true
showAlertDialog(
this,
getString(R.string.unrestricted_battery_mode),
getString(R.string.suggest_unrestricted_mode),
getString(R.string.title_activity_settings),
getString(R.string.cancel),
{
/* Since opening the right settings page might be device dependent, using
https://github.com/WaseemSabir/BatteryPermissionHelper
directly appeared like a promising idea.
However, this simply closed the popup and did not make
the settings page appear on a Pixel as well as a Xiaomi device.
Used the standard intent instead of using this library as
it shows a list of all the apps on the device and allows users to
turn battery optimisation off.
*/
val batteryOptimisationSettingsIntent = Intent(
Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
)
startActivity(batteryOptimisationSettingsIntent)
// calling checkImageQuality after battery dialog is interacted with
// so that 2 dialogs do not pop up simultaneously
UploadMediaPresenter.isBatteryDialogShowing = false
},
{
UploadMediaPresenter.isBatteryDialogShowing = false
}
)
defaultKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", true)
}
}
/**
* If the permission for Location is turned on and certain
* conditions are met, returns current location of the user.
*/
private fun handleLocation() {
val locationPermissionsHelper = LocationPermissionsHelper(
this, locationManager!!, null
)
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
currLocation = locationManager!!.getLastLocation()
}
if (currLocation != null) {
val locationDifference = getLocationDifference(currLocation!!, prevLocation)
val isLocationTagUnchecked = isLocationTagUncheckedInTheSettings
/* Remove location if the user has unchecked the Location EXIF tag in the
Manage EXIF Tags setting or turned "Record location for in-app shots" off.
Also, location information is discarded if the difference between
current location and location recorded just before capturing the image
is greater than 100 meters */
if (isLocationTagUnchecked || locationDifference > 100 || !defaultKvStore.getBoolean("inAppCameraLocationPref")
|| !isInAppCameraUpload
) {
currLocation = null
}
}
}
companion object {
private var uploadIsOfAPlace = false
const val EXTRA_FILES: String = "commons_image_exta"
const val LOCATION_BEFORE_IMAGE_CAPTURE: String = "user_location_before_image_capture"
const val IN_APP_CAMERA_UPLOAD: String = "in_app_camera_upload"
/**
* Stores all nearby places found and related users response for
* each place while uploading media
*/
@JvmField
var nearbyPopupAnswers: MutableMap<Place, Boolean>? = null
const val keyForCurrentUploadImagesSize: String = "CurrentUploadImagesSize"
const val storeNameForCurrentUploadImagesSize: String = "CurrentUploadImageQualities"
/**
* Sets the flag indicating whether the upload is of a specific place.
*
* @param uploadOfAPlace a boolean value indicating whether the upload is of place.
*/
@JvmStatic
fun setUploadIsOfAPlace(uploadOfAPlace: Boolean) {
uploadIsOfAPlace = uploadOfAPlace
}
}
}

View file

@ -8,7 +8,7 @@ import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
abstract class UploadBaseFragment : CommonsDaggerSupportFragment() {
var callback: Callback? = null
protected open fun onBecameVisible() = Unit
open fun onBecameVisible() = Unit
interface Callback {
val totalNumberOfSteps: Int

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.upload
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
/**
* The contract using which the UplaodActivity would communicate with its presenter
@ -73,5 +74,7 @@ interface UploadContract {
* @param uploadItemIndex Index of next image, whose quality is to be checked
*/
fun checkImageQuality(uploadItemIndex: Int)
fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore)
}
}

View file

@ -5,7 +5,6 @@ import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.filepicker.MimeTypeMapWrapper.Companion.getExtensionFromMimeType
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.utils.ImageUtils
import io.reactivex.subjects.BehaviorSubject
class UploadItem(
var mediaUri: Uri?,

View file

@ -8,7 +8,7 @@ import kotlinx.parcelize.Parcelize
* Holds a description of an item being uploaded by [UploadActivity]
*/
@Parcelize
data class UploadMediaDetail constructor(
data class UploadMediaDetail(
/**
* The language code ie. "en" or "fr".
* @param languageCode The language code ie. "en" or "fr".
@ -18,19 +18,19 @@ data class UploadMediaDetail constructor(
* The description text for the item being uploaded.
* @param descriptionText The description text.
*/
var descriptionText: String = "",
var descriptionText: String? = "",
/**
* The caption text for the item being uploaded.
* @param captionText The caption text.
*/
var captionText: String = "",
var captionText: String? = "",
) : Parcelable {
fun javaCopy() = copy()
constructor(place: Place) : this(
place.language,
place.longDescription,
place.name,
constructor(place: Place?) : this(
place?.language,
place?.longDescription,
place?.name,
)
/**

View file

@ -1,633 +0,0 @@
package fr.free.nrw.commons.upload;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.speech.RecognizerIntent;
import android.text.Editable;
import android.text.InputFilter;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.textfield.TextInputLayout;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.databinding.RowItemDescriptionBinding;
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.ui.PasteSensitiveTextInputEditText;
import fr.free.nrw.commons.utils.AbstractTextWatcher;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import timber.log.Timber;
public class UploadMediaDetailAdapter extends
RecyclerView.Adapter<UploadMediaDetailAdapter.ViewHolder> {
RecentLanguagesDao recentLanguagesDao;
private List<UploadMediaDetail> uploadMediaDetails;
private Callback callback;
private EventListener eventListener;
private HashMap<Integer, String> selectedLanguages;
private final String savedLanguageValue;
private TextView recentLanguagesTextView;
private View separator;
private ListView languageHistoryListView;
private int currentPosition;
private Fragment fragment;
private Activity activity;
private final ActivityResultLauncher<Intent> voiceInputResultLauncher;
private SelectedVoiceIcon selectedVoiceIcon;
private RowItemDescriptionBinding binding;
public UploadMediaDetailAdapter(Fragment fragment, String savedLanguageValue,
RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher<Intent> voiceInputResultLauncher) {
uploadMediaDetails = new ArrayList<>();
selectedLanguages = new HashMap<>();
this.savedLanguageValue = savedLanguageValue;
this.recentLanguagesDao = recentLanguagesDao;
this.fragment = fragment;
this.voiceInputResultLauncher = voiceInputResultLauncher;
}
public UploadMediaDetailAdapter(Activity activity, final String savedLanguageValue,
List<UploadMediaDetail> uploadMediaDetails, RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher<Intent> voiceInputResultLauncher) {
this.uploadMediaDetails = uploadMediaDetails;
selectedLanguages = new HashMap<>();
this.savedLanguageValue = savedLanguageValue;
this.recentLanguagesDao = recentLanguagesDao;
this.activity = activity;
this.voiceInputResultLauncher = voiceInputResultLauncher;
}
public void setCallback(Callback callback) {
this.callback = callback;
}
public void setEventListener(EventListener eventListener) {
this.eventListener = eventListener;
}
public void setItems(List<UploadMediaDetail> uploadMediaDetails) {
this.uploadMediaDetails = uploadMediaDetails;
selectedLanguages = new HashMap<>();
notifyDataSetChanged();
}
public List<UploadMediaDetail> getItems() {
return uploadMediaDetails;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
binding = RowItemDescriptionBinding.inflate(inflater, parent, false);
return new ViewHolder(binding.getRoot());
}
/**
* This is a workaround for a known bug by android here
* https://issuetracker.google.com/issues/37095917 makes the edit text on second and subsequent
* fragments inside an adapter receptive to long click for copy/paste options
*
* @param holder the view holder
*/
@Override
public void onViewAttachedToWindow(@NonNull final ViewHolder holder) {
super.onViewAttachedToWindow(holder);
holder.captionItemEditText.setEnabled(false);
holder.captionItemEditText.setEnabled(true);
holder.descItemEditText.setEnabled(false);
holder.descItemEditText.setEnabled(true);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(position);
}
@Override
public int getItemCount() {
return uploadMediaDetails.size();
}
public void addDescription(UploadMediaDetail uploadMediaDetail) {
selectedLanguages.put(uploadMediaDetails.size(), "en");
this.uploadMediaDetails.add(uploadMediaDetail);
notifyItemInserted(uploadMediaDetails.size());
}
private void startSpeechInput(String locale) {
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
);
intent.putExtra(
RecognizerIntent.EXTRA_LANGUAGE,
locale
);
try {
voiceInputResultLauncher.launch(intent);
} catch (Exception e) {
Timber.e(e.getMessage());
}
}
/**
* Handles the result of the speech input by processing the spoken text.
* If the spoken text is not empty, it capitalizes the first letter of the spoken text
* and updates the appropriate field (caption or description) of the current
* UploadMediaDetail based on the selected voice icon.
* Finally, it notifies the adapter that the data set has changed.
*
* @param spokenText the text input received from speech recognition.
*/
public void handleSpeechResult(String spokenText) {
if (!spokenText.isEmpty()) {
String spokenTextCapitalized =
spokenText.substring(0, 1).toUpperCase() + spokenText.substring(1);
if (currentPosition < uploadMediaDetails.size()) {
UploadMediaDetail uploadMediaDetail = uploadMediaDetails.get(currentPosition);
switch (selectedVoiceIcon) {
case CAPTION:
uploadMediaDetail.setCaptionText(spokenTextCapitalized);
break;
case DESCRIPTION:
uploadMediaDetail.setDescriptionText(spokenTextCapitalized);
break;
}
notifyDataSetChanged();
}
}
}
/**
* Remove description based on position from the list and notifies the RecyclerView Adapter that
* data in adapter has been removed at that particular position.
*
* @param uploadMediaDetail
* @param position
*/
public void removeDescription(final UploadMediaDetail uploadMediaDetail, final int position) {
selectedLanguages.remove(position);
this.uploadMediaDetails.remove(uploadMediaDetail);
int i = position + 1;
while (selectedLanguages.containsKey(i)) {
selectedLanguages.remove(i);
i++;
}
notifyItemRemoved(position);
notifyItemRangeChanged(position, uploadMediaDetails.size() - position);
updateAddButtonVisibility();
}
public class ViewHolder extends RecyclerView.ViewHolder {
TextView descriptionLanguages ;
PasteSensitiveTextInputEditText descItemEditText;
TextInputLayout descInputLayout;
PasteSensitiveTextInputEditText captionItemEditText;
TextInputLayout captionInputLayout;
ImageView removeButton;
ImageView addButton;
ConstraintLayout clParent;
LinearLayout betterCaptionLinearLayout;
LinearLayout betterDescriptionLinearLayout;
private
AbstractTextWatcher captionListener;
AbstractTextWatcher descriptionListener;
public ViewHolder(View itemView) {
super(itemView);
Timber.i("descItemEditText:" + descItemEditText);
}
public void bind(int position) {
UploadMediaDetail uploadMediaDetail = uploadMediaDetails.get(position);
Timber.d("UploadMediaDetail is " + uploadMediaDetail);
descriptionLanguages = binding.descriptionLanguages;
descItemEditText = binding.descriptionItemEditText;
descInputLayout = binding.descriptionItemEditTextInputLayout;
captionItemEditText = binding.captionItemEditText;
captionInputLayout = binding.captionItemEditTextInputLayout;
removeButton = binding.btnRemove;
addButton = binding.btnAdd;
clParent = binding.clParent;
betterCaptionLinearLayout = binding.llWriteBetterCaption;
betterDescriptionLinearLayout = binding.llWriteBetterDescription;
descriptionLanguages.setFocusable(false);
captionItemEditText.addTextChangedListener(new AbstractTextWatcher(
value -> {
if (position == 0) {
eventListener.onPrimaryCaptionTextChange(value.length() != 0);
}
}));
captionItemEditText.removeTextChangedListener(captionListener);
descItemEditText.removeTextChangedListener(descriptionListener);
captionItemEditText.setText(uploadMediaDetail.getCaptionText());
descItemEditText.setText(uploadMediaDetail.getDescriptionText());
captionInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM);
captionInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice);
captionInputLayout.setEndIconOnClickListener(v -> {
currentPosition = position;
selectedVoiceIcon = SelectedVoiceIcon.CAPTION;
startSpeechInput(descriptionLanguages.getText().toString());
});
descInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM);
descInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice);
descInputLayout.setEndIconOnClickListener(v -> {
currentPosition = position;
selectedVoiceIcon = SelectedVoiceIcon.DESCRIPTION;
startSpeechInput(descriptionLanguages.getText().toString());
});
if (position == 0) {
removeButton.setVisibility(View.GONE);
betterCaptionLinearLayout.setVisibility(View.VISIBLE);
betterCaptionLinearLayout.setOnClickListener(
v -> callback.showAlert(R.string.media_detail_caption, R.string.caption_info));
betterDescriptionLinearLayout.setVisibility(View.VISIBLE);
betterDescriptionLinearLayout.setOnClickListener(
v -> callback.showAlert(R.string.media_detail_description,
R.string.description_info));
Objects.requireNonNull(captionInputLayout.getEditText())
.setFilters(new InputFilter[]{
new UploadMediaDetailInputFilter()
});
} else {
removeButton.setVisibility(View.VISIBLE);
betterCaptionLinearLayout.setVisibility(View.GONE);
betterDescriptionLinearLayout.setVisibility(View.GONE);
}
removeButton.setOnClickListener(v -> removeDescription(uploadMediaDetail, position));
captionListener = new AbstractTextWatcher(
captionText -> uploadMediaDetail.setCaptionText(
convertIdeographicSpaceToLatinSpace(captionText.strip()))
);
descriptionListener = new AbstractTextWatcher(
descriptionText -> uploadMediaDetail.setDescriptionText(descriptionText));
captionItemEditText.addTextChangedListener(captionListener);
initLanguage(position, uploadMediaDetail);
descItemEditText.addTextChangedListener(descriptionListener);
initLanguage(position, uploadMediaDetail);
if (fragment != null) {
FrameLayout.LayoutParams newLayoutParams = (FrameLayout.LayoutParams) clParent.getLayoutParams();
newLayoutParams.topMargin = 0;
newLayoutParams.leftMargin = 0;
newLayoutParams.rightMargin = 0;
newLayoutParams.bottomMargin = 0;
clParent.setLayoutParams(newLayoutParams);
}
updateAddButtonVisibility();
addButton.setOnClickListener(v -> eventListener.addLanguage());
//If the description was manually added by the user, it deserves focus, if not, let the user decide
if (uploadMediaDetail.isManuallyAdded()) {
captionItemEditText.requestFocus();
} else {
captionItemEditText.clearFocus();
}
}
private void initLanguage(int position, UploadMediaDetail description) {
final List<Language> recentLanguages = recentLanguagesDao.getRecentLanguages();
LanguagesAdapter languagesAdapter = new LanguagesAdapter(
descriptionLanguages.getContext(),
selectedLanguages
);
descriptionLanguages.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
Dialog dialog = new Dialog(view.getContext());
dialog.setContentView(R.layout.dialog_select_language);
dialog.setCancelable(false);
dialog.getWindow().setLayout(
(int) (view.getContext().getResources().getDisplayMetrics().widthPixels
* 0.90),
(int) (view.getContext().getResources().getDisplayMetrics().heightPixels
* 0.90));
dialog.show();
EditText editText = dialog.findViewById(R.id.search_language);
ListView listView = dialog.findViewById(R.id.language_list);
final Button cancelButton = dialog.findViewById(R.id.cancel_button);
languageHistoryListView = dialog.findViewById(R.id.language_history_list);
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches);
separator = dialog.findViewById(R.id.separator);
setUpRecentLanguagesSection(recentLanguages);
listView.setAdapter(languagesAdapter);
cancelButton.setOnClickListener(v -> dialog.dismiss());
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, view1, position, id) -> {
onRecentLanguageClicked(dialog, adapterView, position, description);
});
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i,
long l) {
description.setSelectedLanguageIndex(i);
String languageCode = ((LanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(i);
description.setLanguageCode(languageCode);
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));
selectedLanguages.clear();
selectedLanguages.put(position, languageCode);
((LanguagesAdapter) adapterView
.getAdapter()).setSelectedLangCode(languageCode);
Timber.d("Description language code is: " + languageCode);
descriptionLanguages.setText(languageCode);
dialog.dismiss();
}
});
dialog.setOnDismissListener(
dialogInterface -> languagesAdapter.getFilter().filter(""));
}
});
if (description.getSelectedLanguageIndex() == -1) {
if (!TextUtils.isEmpty(savedLanguageValue)) {
// If user has chosen a default language from settings activity
// savedLanguageValue is not null
if (!TextUtils.isEmpty(description.getLanguageCode())) {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
} else {
description.setLanguageCode(savedLanguageValue);
descriptionLanguages.setText(savedLanguageValue);
selectedLanguages.remove(position);
selectedLanguages.put(position, savedLanguageValue);
}
} else if (!TextUtils.isEmpty(description.getLanguageCode())) {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
} else {
//Checking whether Language Code attribute is null or not.
if (uploadMediaDetails.get(position).getLanguageCode() != null) {
//If it is not null that means it is fetching details from the previous
// upload (i.e. when user has pressed copy previous caption & description)
//hence providing same language code for the current upload.
descriptionLanguages.setText(uploadMediaDetails.get(position)
.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, uploadMediaDetails.get(position)
.getLanguageCode());
} else {
if (position == 0) {
final int defaultLocaleIndex = languagesAdapter
.getIndexOfUserDefaultLocale(descriptionLanguages
.getContext());
descriptionLanguages
.setText(languagesAdapter.getLanguageCode(defaultLocaleIndex));
description.setLanguageCode(
languagesAdapter.getLanguageCode(defaultLocaleIndex));
selectedLanguages.remove(position);
selectedLanguages.put(position,
languagesAdapter.getLanguageCode(defaultLocaleIndex));
} else {
description.setLanguageCode(languagesAdapter.getLanguageCode(0));
descriptionLanguages.setText(languagesAdapter.getLanguageCode(0));
selectedLanguages.remove(position);
selectedLanguages.put(position, languagesAdapter.getLanguageCode(0));
}
}
}
} else {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
}
}
/**
* Handles click event for recent language section
*/
private void onRecentLanguageClicked(final Dialog dialog, final AdapterView<?> adapterView,
final int position, final UploadMediaDetail description) {
description.setSelectedLanguageIndex(position);
final String languageCode = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(position);
description.setLanguageCode(languageCode);
final String languageName = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageName(position);
final boolean isExists = recentLanguagesDao.findRecentLanguage(languageCode);
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode);
}
recentLanguagesDao.addRecentLanguage(new Language(languageName, languageCode));
selectedLanguages.clear();
selectedLanguages.put(position, languageCode);
((RecentLanguagesAdapter) adapterView
.getAdapter()).setSelectedLangCode(languageCode);
Timber.d("Description language code is: %s", languageCode);
if (descriptionLanguages!=null) {
descriptionLanguages.setText(languageCode);
}
dialog.dismiss();
}
/**
* Hides recent languages section
*/
private void hideRecentLanguagesSection() {
languageHistoryListView.setVisibility(View.GONE);
recentLanguagesTextView.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
*/
private void setUpRecentLanguagesSection(final List<Language> recentLanguages) {
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);
if (descriptionLanguages!=null) {
final RecentLanguagesAdapter recentLanguagesAdapter
= new RecentLanguagesAdapter(
descriptionLanguages.getContext(),
recentLanguagesDao.getRecentLanguages(),
selectedLanguages);
languageHistoryListView.setAdapter(recentLanguagesAdapter);
}
}
}
/**
* Convert Ideographic space to Latin space
*
* @param source the source text
* @return a string with Latin spaces instead of Ideographic spaces
*/
public String convertIdeographicSpaceToLatinSpace(String source) {
Pattern ideographicSpacePattern = Pattern.compile("\\x{3000}");
return ideographicSpacePattern.matcher(source).replaceAll(" ");
}
}
/**
* Hides the visibility of the "Add" button for all items in the RecyclerView except
* the last item in RecyclerView
*/
private void updateAddButtonVisibility() {
int lastItemPosition = getItemCount() - 1;
// Hide Add Button for all items
for (int i = 0; i < getItemCount(); i++) {
if (fragment != null) {
if (fragment.getView() != null) {
ViewHolder holder = (ViewHolder) ((RecyclerView) fragment.getView()
.findViewById(R.id.rv_descriptions)).findViewHolderForAdapterPosition(i);
if (holder != null) {
holder.addButton.setVisibility(View.GONE);
}
}
} else {
if (this.activity != null) {
ViewHolder holder = (ViewHolder) ((RecyclerView) activity.findViewById(
R.id.rv_descriptions_captions)).findViewHolderForAdapterPosition(i);
if (holder != null) {
holder.addButton.setVisibility(View.GONE);
}
}
}
}
// Show Add Button for the last item
if (fragment != null) {
if (fragment.getView() != null) {
ViewHolder lastItemHolder = (ViewHolder) ((RecyclerView) fragment.getView()
.findViewById(R.id.rv_descriptions)).findViewHolderForAdapterPosition(
lastItemPosition);
if (lastItemHolder != null) {
lastItemHolder.addButton.setVisibility(View.VISIBLE);
}
}
} else {
if (this.activity != null) {
ViewHolder lastItemHolder = (ViewHolder) ((RecyclerView) activity
.findViewById(R.id.rv_descriptions_captions)).findViewHolderForAdapterPosition(
lastItemPosition);
if (lastItemHolder != null) {
lastItemHolder.addButton.setVisibility(View.VISIBLE);
}
}
}
}
public interface Callback {
void showAlert(int mediaDetailDescription, int descriptionInfo);
}
public interface EventListener {
void onPrimaryCaptionTextChange(boolean isNotEmpty);
void addLanguage();
}
enum SelectedVoiceIcon {
CAPTION,
DESCRIPTION
}
}

View file

@ -0,0 +1,563 @@
package fr.free.nrw.commons.upload
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH
import android.speech.RecognizerIntent.EXTRA_LANGUAGE
import android.speech.RecognizerIntent.EXTRA_LANGUAGE_MODEL
import android.speech.RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
import android.text.Editable
import android.text.InputFilter
import android.text.TextUtils
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.RowItemDescriptionBinding
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.utils.AbstractTextWatcher
import timber.log.Timber
import java.util.Locale
import java.util.regex.Pattern
class UploadMediaDetailAdapter : RecyclerView.Adapter<UploadMediaDetailAdapter.ViewHolder> {
private var uploadMediaDetails: MutableList<UploadMediaDetail>
private var selectedLanguages: MutableMap<Int, String>
private val savedLanguageValue: String
private var recentLanguagesTextView: TextView? = null
private var separator: View? = null
private var languageHistoryListView: ListView? = null
private var currentPosition = 0
private var fragment: Fragment? = null
private var activity: Activity? = null
private val voiceInputResultLauncher: ActivityResultLauncher<Intent>
private var selectedVoiceIcon: SelectedVoiceIcon? = null
var recentLanguagesDao: RecentLanguagesDao
var callback: Callback? = null
var eventListener: EventListener? = null
var items: List<UploadMediaDetail>
get() = uploadMediaDetails
set(value) {
uploadMediaDetails = value.toMutableList()
selectedLanguages = mutableMapOf()
notifyDataSetChanged()
}
constructor(
fragment: Fragment?,
savedLanguageValue: String,
recentLanguagesDao: RecentLanguagesDao,
voiceInputResultLauncher: ActivityResultLauncher<Intent>
) {
uploadMediaDetails = ArrayList()
selectedLanguages = mutableMapOf()
this.savedLanguageValue = savedLanguageValue
this.recentLanguagesDao = recentLanguagesDao
this.fragment = fragment
this.voiceInputResultLauncher = voiceInputResultLauncher
}
constructor(
activity: Activity?,
savedLanguageValue: String,
uploadMediaDetails: MutableList<UploadMediaDetail>,
recentLanguagesDao: RecentLanguagesDao,
voiceInputResultLauncher: ActivityResultLauncher<Intent>
) {
this.uploadMediaDetails = uploadMediaDetails
selectedLanguages = HashMap()
this.savedLanguageValue = savedLanguageValue
this.recentLanguagesDao = recentLanguagesDao
this.activity = activity
this.voiceInputResultLauncher = voiceInputResultLauncher
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return ViewHolder(RowItemDescriptionBinding.inflate(inflater, parent, false))
}
/**
* This is a workaround for a known bug by android here
* https://issuetracker.google.com/issues/37095917 makes the edit text on second and subsequent
* fragments inside an adapter receptive to long click for copy/paste options
*
* @param holder the view holder
*/
override fun onViewAttachedToWindow(holder: ViewHolder) {
super.onViewAttachedToWindow(holder)
holder.binding.captionItemEditText.isEnabled = false
holder.binding.captionItemEditText.isEnabled = true
holder.binding.descriptionItemEditText.isEnabled = false
holder.binding.descriptionItemEditText.isEnabled = true
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount(): Int {
return uploadMediaDetails.size
}
fun addDescription(uploadMediaDetail: UploadMediaDetail) {
selectedLanguages[uploadMediaDetails.size] = "en"
uploadMediaDetails.add(uploadMediaDetail)
notifyItemInserted(uploadMediaDetails.size)
}
private fun startSpeechInput(locale: String) {
try {
voiceInputResultLauncher.launch(Intent(ACTION_RECOGNIZE_SPEECH).apply {
putExtra(EXTRA_LANGUAGE_MODEL, LANGUAGE_MODEL_FREE_FORM)
putExtra(EXTRA_LANGUAGE, locale)
})
} catch (e: Exception) {
Timber.e(e)
}
}
/**
* Handles the result of the speech input by processing the spoken text.
* If the spoken text is not empty, it capitalizes the first letter of the spoken text
* and updates the appropriate field (caption or description) of the current
* UploadMediaDetail based on the selected voice icon.
* Finally, it notifies the adapter that the data set has changed.
*
* @param spokenText the text input received from speech recognition.
*/
fun handleSpeechResult(spokenText: String) {
if (spokenText.isNotEmpty()) {
val spokenTextCapitalized =
spokenText.substring(0, 1).uppercase(Locale.getDefault()) + spokenText.substring(1)
if (currentPosition < uploadMediaDetails.size) {
val uploadMediaDetail = uploadMediaDetails[currentPosition]
when (selectedVoiceIcon) {
SelectedVoiceIcon.CAPTION -> uploadMediaDetail.captionText =
spokenTextCapitalized
SelectedVoiceIcon.DESCRIPTION -> uploadMediaDetail.descriptionText =
spokenTextCapitalized
null -> {}
}
notifyDataSetChanged()
}
}
}
/**
* Remove description based on position from the list and notifies the RecyclerView Adapter that
* data in adapter has been removed at that particular position.
*/
fun removeDescription(uploadMediaDetail: UploadMediaDetail, position: Int) {
selectedLanguages.remove(position)
uploadMediaDetails.remove(uploadMediaDetail)
var i = position + 1
while (selectedLanguages.containsKey(i)) {
selectedLanguages.remove(i)
i++
}
notifyItemRemoved(position)
notifyItemRangeChanged(position, uploadMediaDetails.size - position)
updateAddButtonVisibility()
}
inner class ViewHolder(val binding: RowItemDescriptionBinding) :
RecyclerView.ViewHolder(binding.root) {
var addButton: ImageView? = null
var clParent: ConstraintLayout? = null
var betterCaptionLinearLayout: LinearLayout? = null
var betterDescriptionLinearLayout: LinearLayout? = null
private var captionListener: AbstractTextWatcher? = null
var descriptionListener: AbstractTextWatcher? = null
fun bind(position: Int) {
val uploadMediaDetail = uploadMediaDetails[position]
Timber.d("UploadMediaDetail is %s", uploadMediaDetail)
addButton = binding.btnAdd
clParent = binding.clParent
betterCaptionLinearLayout = binding.llWriteBetterCaption
betterDescriptionLinearLayout = binding.llWriteBetterDescription
binding.descriptionLanguages.isFocusable = false
binding.captionItemEditText.addTextChangedListener(AbstractTextWatcher { value: String ->
if (position == 0) {
eventListener!!.onPrimaryCaptionTextChange(value.length != 0)
}
})
binding.captionItemEditText.removeTextChangedListener(captionListener)
binding.descriptionItemEditText.removeTextChangedListener(descriptionListener)
binding.captionItemEditText.setText(uploadMediaDetail.captionText)
binding.descriptionItemEditText.setText(uploadMediaDetail.descriptionText)
binding.captionItemEditTextInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.captionItemEditTextInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice)
binding.captionItemEditTextInputLayout.setEndIconOnClickListener { v: View? ->
currentPosition = position
selectedVoiceIcon = SelectedVoiceIcon.CAPTION
startSpeechInput(binding.descriptionLanguages.text.toString())
}
binding.descriptionItemEditTextInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.descriptionItemEditTextInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice)
binding.descriptionItemEditTextInputLayout.setEndIconOnClickListener { v: View? ->
currentPosition = position
selectedVoiceIcon = SelectedVoiceIcon.DESCRIPTION
startSpeechInput(binding.descriptionLanguages.text.toString())
}
if (position == 0) {
binding.btnRemove.visibility = View.GONE
betterCaptionLinearLayout!!.visibility = View.VISIBLE
betterCaptionLinearLayout!!.setOnClickListener { v: View? ->
callback!!.showAlert(
R.string.media_detail_caption,
R.string.caption_info
)
}
betterDescriptionLinearLayout!!.visibility = View.VISIBLE
betterDescriptionLinearLayout!!.setOnClickListener { v: View? ->
callback!!.showAlert(
R.string.media_detail_description,
R.string.description_info
)
}
binding.captionItemEditTextInputLayout.editText?.let {
it.filters = arrayOf<InputFilter>(UploadMediaDetailInputFilter())
}
} else {
binding.btnRemove.visibility = View.VISIBLE
betterCaptionLinearLayout!!.visibility = View.GONE
betterDescriptionLinearLayout!!.visibility = View.GONE
}
binding.btnRemove.setOnClickListener { v: View? ->
removeDescription(
uploadMediaDetail,
position
)
}
captionListener = AbstractTextWatcher { captionText: String ->
uploadMediaDetail.captionText =
convertIdeographicSpaceToLatinSpace(captionText.trim())
}
descriptionListener = AbstractTextWatcher { value: String? ->
uploadMediaDetail.descriptionText = value
}
binding.captionItemEditText.addTextChangedListener(captionListener)
initLanguage(position, uploadMediaDetail)
binding.descriptionItemEditText.addTextChangedListener(descriptionListener)
initLanguage(position, uploadMediaDetail)
if (fragment != null) {
val newLayoutParams = clParent!!.layoutParams as FrameLayout.LayoutParams
newLayoutParams.topMargin = 0
newLayoutParams.leftMargin = 0
newLayoutParams.rightMargin = 0
newLayoutParams.bottomMargin = 0
clParent!!.layoutParams = newLayoutParams
}
updateAddButtonVisibility()
addButton!!.setOnClickListener { v: View? -> eventListener!!.addLanguage() }
//If the description was manually added by the user, it deserves focus, if not, let the user decide
if (uploadMediaDetail.isManuallyAdded) {
binding.captionItemEditText.requestFocus()
} else {
binding.captionItemEditText.clearFocus()
}
}
private fun initLanguage(position: Int, description: UploadMediaDetail) {
val recentLanguages = recentLanguagesDao.getRecentLanguages()
val languagesAdapter = LanguagesAdapter(
binding.descriptionLanguages.context,
selectedLanguages
)
binding.descriptionLanguages.setOnClickListener { view ->
val dialog = Dialog(view.context)
dialog.setContentView(R.layout.dialog_select_language)
dialog.setCancelable(false)
dialog.window!!.setLayout(
(view.context.resources.displayMetrics.widthPixels
* 0.90).toInt(),
(view.context.resources.displayMetrics.heightPixels
* 0.90).toInt()
)
dialog.show()
val editText =
dialog.findViewById<EditText>(R.id.search_language)
val listView =
dialog.findViewById<ListView>(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)
listView.adapter = languagesAdapter
editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) =
hideRecentLanguagesSection()
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {
languagesAdapter.filter.filter(charSequence)
}
override fun afterTextChanged(editable: Editable) = Unit
})
languageHistoryListView?.setOnItemClickListener { adapterView: AdapterView<*>, view1: View?, position: Int, id: Long ->
onRecentLanguageClicked(dialog, adapterView, position, description)
}
listView.onItemClickListener = OnItemClickListener { adapterView, _, i, l ->
description.selectedLanguageIndex = i
val languageCode = (adapterView.adapter as LanguagesAdapter).getLanguageCode(i)
description.languageCode = languageCode
val languageName = (adapterView.adapter as LanguagesAdapter).getLanguageName(i)
val isExists = recentLanguagesDao.findRecentLanguage(languageCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode)
}
recentLanguagesDao.addRecentLanguage(Language(languageName, languageCode))
selectedLanguages.clear()
selectedLanguages[position] = languageCode
(adapterView.adapter as LanguagesAdapter).selectedLangCode = languageCode
Timber.d("Description language code is: %s", languageCode)
binding.descriptionLanguages.text = languageCode
dialog.dismiss()
}
dialog.setOnDismissListener {
languagesAdapter.filter.filter("")
}
}
if (description.selectedLanguageIndex == -1) {
if (!TextUtils.isEmpty(savedLanguageValue)) {
// If user has chosen a default language from settings activity
// savedLanguageValue is not null
if (!TextUtils.isEmpty(description.languageCode)) {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = description.languageCode!!
} else {
description.languageCode = savedLanguageValue
binding.descriptionLanguages.text = savedLanguageValue
selectedLanguages.remove(position)
selectedLanguages[position] = savedLanguageValue
}
} else if (!TextUtils.isEmpty(description.languageCode)) {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = description.languageCode!!
} else {
//Checking whether Language Code attribute is null or not.
if (uploadMediaDetails[position].languageCode != null) {
//If it is not null that means it is fetching details from the previous
// upload (i.e. when user has pressed copy previous caption & description)
//hence providing same language code for the current upload.
binding.descriptionLanguages.text = uploadMediaDetails[position]
.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = uploadMediaDetails[position].languageCode!!
} else {
if (position == 0) {
val defaultLocaleIndex = languagesAdapter.getIndexOfUserDefaultLocale(
binding.descriptionLanguages.getContext())
binding.descriptionLanguages.setText(languagesAdapter.getLanguageCode(defaultLocaleIndex))
description.languageCode = languagesAdapter.getLanguageCode(defaultLocaleIndex)
selectedLanguages.remove(position)
selectedLanguages[position] =
languagesAdapter.getLanguageCode(defaultLocaleIndex)
} else {
description.languageCode = languagesAdapter.getLanguageCode(0)
binding.descriptionLanguages.text = languagesAdapter.getLanguageCode(0)
selectedLanguages.remove(position)
selectedLanguages[position] = languagesAdapter.getLanguageCode(0)
}
}
}
} else {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
description.languageCode?.let {
selectedLanguages[position] = it
}
}
}
/**
* Handles click event for recent language section
*/
private fun onRecentLanguageClicked(
dialog: Dialog, adapterView: AdapterView<*>,
position: Int, description: UploadMediaDetail
) {
description.selectedLanguageIndex = position
val languageCode = (adapterView.adapter as RecentLanguagesAdapter)
.getLanguageCode(position)
description.languageCode = languageCode
val languageName = (adapterView.adapter as RecentLanguagesAdapter)
.getLanguageName(position)
val isExists = recentLanguagesDao.findRecentLanguage(languageCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode)
}
recentLanguagesDao.addRecentLanguage(Language(languageName, languageCode))
selectedLanguages.clear()
selectedLanguages[position] = languageCode
(adapterView
.adapter as RecentLanguagesAdapter).selectedLangCode = languageCode
Timber.d("Description language code is: %s", languageCode)
binding.descriptionLanguages.text = languageCode
dialog.dismiss()
}
/**
* Hides recent languages section
*/
private fun hideRecentLanguagesSection() {
languageHistoryListView!!.visibility = View.GONE
recentLanguagesTextView!!.visibility = View.GONE
separator!!.visibility = View.GONE
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
*/
private fun setUpRecentLanguagesSection(recentLanguages: List<Language>) {
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(
binding.descriptionLanguages.context,
recentLanguagesDao.getRecentLanguages(),
selectedLanguages
)
languageHistoryListView!!.adapter = recentLanguagesAdapter
}
}
/**
* Convert Ideographic space to Latin space
*
* @param source the source text
* @return a string with Latin spaces instead of Ideographic spaces
*/
fun convertIdeographicSpaceToLatinSpace(source: String): String {
val ideographicSpacePattern = Pattern.compile("\\x{3000}")
return ideographicSpacePattern.matcher(source).replaceAll(" ")
}
}
/**
* Hides the visibility of the "Add" button for all items in the RecyclerView except
* the last item in RecyclerView
*/
private fun updateAddButtonVisibility() {
val lastItemPosition = itemCount - 1
// Hide Add Button for all items
for (i in 0 until itemCount) {
if (fragment != null) {
if (fragment!!.view != null) {
val holder = (fragment!!.requireView().findViewById<View>(R.id.rv_descriptions) as RecyclerView).findViewHolderForAdapterPosition(i) as ViewHolder?
if (holder != null) {
holder.addButton!!.visibility = View.GONE
}
}
} else {
if (activity != null) {
val holder = (activity!!.findViewById<View>(R.id.rv_descriptions_captions) as RecyclerView).findViewHolderForAdapterPosition(i) as ViewHolder?
if (holder != null) {
holder.addButton!!.visibility = View.GONE
}
}
}
}
// Show Add Button for the last item
if (fragment != null) {
if (fragment!!.view != null) {
val lastItemHolder = (fragment!!.requireView().findViewById<View>(R.id.rv_descriptions) as RecyclerView).findViewHolderForAdapterPosition(lastItemPosition) as ViewHolder?
if (lastItemHolder != null) {
lastItemHolder.addButton!!.visibility = View.VISIBLE
}
}
} else {
if (activity != null) {
val lastItemHolder = (activity!!.findViewById<View>(R.id.rv_descriptions_captions) as RecyclerView).findViewHolderForAdapterPosition(lastItemPosition) as ViewHolder?
if (lastItemHolder != null) {
lastItemHolder.addButton!!.visibility = View.VISIBLE
}
}
}
}
fun interface Callback {
fun showAlert(mediaDetailDescription: Int, descriptionInfo: Int)
}
interface EventListener {
fun onPrimaryCaptionTextChange(isNotEmpty: Boolean)
fun addLanguage()
}
internal enum class SelectedVoiceIcon {
CAPTION,
DESCRIPTION
}
}

View file

@ -1,297 +0,0 @@
package fr.free.nrw.commons.upload;
import android.content.Context;
import android.net.Uri;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.disposables.CompositeDisposable;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.jetbrains.annotations.NotNull;
import timber.log.Timber;
@Singleton
public class UploadModel {
private final JsonKvStore store;
private final List<String> licenses;
private final Context context;
private String license;
private final Map<String, String> licensesByName;
private final List<UploadItem> items = new ArrayList<>();
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
private final SessionManager sessionManager;
private final FileProcessor fileProcessor;
private final ImageProcessingService imageProcessingService;
private final List<String> selectedCategories = new ArrayList<>();
private final List<DepictedItem> selectedDepictions = new ArrayList<>();
/**
* Existing depicts which are selected
*/
private List<String> selectedExistingDepictions = new ArrayList<>();
@Inject
UploadModel(@Named("licenses") final List<String> licenses,
@Named("default_preferences") final JsonKvStore store,
@Named("licenses_by_name") final Map<String, String> licensesByName,
final Context context,
final SessionManager sessionManager,
final FileProcessor fileProcessor,
final ImageProcessingService imageProcessingService) {
this.licenses = licenses;
this.store = store;
this.license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3);
this.licensesByName = licensesByName;
this.context = context;
this.sessionManager = sessionManager;
this.fileProcessor = fileProcessor;
this.imageProcessingService = imageProcessingService;
}
/**
* cleanup the resources, I am Singleton, preparing for fresh upload
*/
public void cleanUp() {
compositeDisposable.clear();
fileProcessor.cleanup();
items.clear();
selectedCategories.clear();
selectedDepictions.clear();
selectedExistingDepictions.clear();
}
public void setSelectedCategories(List<String> selectedCategories) {
this.selectedCategories.clear();
this.selectedCategories.addAll(selectedCategories);
}
/**
* pre process a one item at a time
*/
public Observable<UploadItem> preProcessImage(final UploadableFile uploadableFile,
final Place place,
final SimilarImageInterface similarImageInterface,
LatLng inAppPictureLocation) {
return Observable.just(
createAndAddUploadItem(uploadableFile, place, similarImageInterface, inAppPictureLocation));
}
/**
* Calls validateImage() of ImageProcessingService to check quality of image
*
* @param uploadItem UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @return Quality of UploadItem
*/
public Single<Integer> getImageQuality(final UploadItem uploadItem, LatLng inAppPictureLocation) {
return imageProcessingService.validateImage(uploadItem, inAppPictureLocation);
}
/**
* Calls checkDuplicateImage() of ImageProcessingService to check if image is duplicate
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
public Single<Integer> checkDuplicateImage(String filePath){
return imageProcessingService.checkDuplicateImage(filePath);
}
/**
* Calls validateCaption() of ImageProcessingService to check caption of image
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
public Single<Integer> getCaptionQuality(final UploadItem uploadItem) {
return imageProcessingService.validateCaption(uploadItem);
}
private UploadItem createAndAddUploadItem(final UploadableFile uploadableFile,
final Place place,
final SimilarImageInterface similarImageInterface,
LatLng inAppPictureLocation) {
final UploadableFile.DateTimeWithSource dateTimeWithSource = uploadableFile
.getFileCreatedDate(context);
long fileCreatedDate = -1;
String createdTimestampSource = "";
String fileCreatedDateString = "";
if (dateTimeWithSource != null) {
fileCreatedDate = dateTimeWithSource.getEpochDate();
fileCreatedDateString = dateTimeWithSource.getDateString();
createdTimestampSource = dateTimeWithSource.getSource();
}
Timber.d("File created date is %d", fileCreatedDate);
final ImageCoordinates imageCoordinates = fileProcessor
.processFileCoordinates(similarImageInterface, uploadableFile.getFilePath(),
inAppPictureLocation);
final UploadItem uploadItem = new UploadItem(
Uri.parse(uploadableFile.getFilePath()),
uploadableFile.getMimeType(context), imageCoordinates, place, fileCreatedDate,
createdTimestampSource,
uploadableFile.getContentUri(),
fileCreatedDateString);
// If an uploadItem of the same uploadableFile has been created before, we return that.
// This is to avoid multiple instances of uploadItem of same file passed around.
if (items.contains(uploadItem)) {
return items.get(items.indexOf(uploadItem));
}
if (place != null) {
uploadItem.getUploadMediaDetails().set(0, new UploadMediaDetail(place));
}
if (!items.contains(uploadItem)) {
items.add(uploadItem);
}
return uploadItem;
}
public int getCount() {
return items.size();
}
public List<UploadItem> getUploads() {
return items;
}
public List<String> getLicenses() {
return licenses;
}
public String getSelectedLicense() {
return license;
}
public void setSelectedLicense(final String licenseName) {
this.license = licensesByName.get(licenseName);
store.putString(Prefs.DEFAULT_LICENSE, license);
}
public Observable<Contribution> buildContributions() {
return Observable.fromIterable(items).map(item ->
{
String imageSHA1 = FileUtils.INSTANCE.getSHA1(context.getContentResolver().openInputStream(item.getContentUri()));
final Contribution contribution = new Contribution(
item, sessionManager, newListOf(selectedDepictions), newListOf(selectedCategories), imageSHA1);
contribution.setHasInvalidLocation(item.hasInvalidLocation());
Timber.d("Created timestamp while building contribution is %s, %s",
item.getCreatedTimestamp(),
new Date(item.getCreatedTimestamp()));
if (item.getCreatedTimestamp() != -1L) {
contribution.setDateCreated(new Date(item.getCreatedTimestamp()));
contribution.setDateCreatedSource(item.getCreatedTimestampSource());
//Set the date only if you have it, else the upload service is gonna try it the other way
}
if (contribution.getWikidataPlace() != null) {
if (item.isWLMUpload()) {
contribution.getWikidataPlace().setMonumentUpload(true);
} else {
contribution.getWikidataPlace().setMonumentUpload(false);
}
}
contribution.setCountryCode(item.getCountryCode());
return contribution;
});
}
public void deletePicture(final String filePath) {
final Iterator<UploadItem> iterator = items.iterator();
while (iterator.hasNext()) {
if (iterator.next().getMediaUri().toString().contains(filePath)) {
iterator.remove();
break;
}
}
if (items.isEmpty()) {
cleanUp();
}
}
public List<UploadItem> getItems() {
return items;
}
public void onDepictItemClicked(DepictedItem depictedItem, Media media) {
if (media == null) {
if (depictedItem.isSelected()) {
selectedDepictions.add(depictedItem);
} else {
selectedDepictions.remove(depictedItem);
}
} else {
if (depictedItem.isSelected()) {
if (media.getDepictionIds().contains(depictedItem.getId())) {
selectedExistingDepictions.add(depictedItem.getId());
} else {
selectedDepictions.add(depictedItem);
}
} else {
if (media.getDepictionIds().contains(depictedItem.getId())) {
selectedExistingDepictions.remove(depictedItem.getId());
if (!media.getDepictionIds().contains(depictedItem.getId())) {
final List<String> depictsList = new ArrayList<>();
depictsList.add(depictedItem.getId());
depictsList.addAll(media.getDepictionIds());
media.setDepictionIds(depictsList);
}
} else {
selectedDepictions.remove(depictedItem);
}
}
}
}
@NotNull
private <T> List<T> newListOf(final List<T> items) {
return items != null ? new ArrayList<>(items) : new ArrayList<>();
}
public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) {
fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates);
items.get(uploadItemIndex).setGpsCoords(imageCoordinates);
}
public List<DepictedItem> getSelectedDepictions() {
return selectedDepictions;
}
/**
* Provides selected existing depicts
*
* @return selected existing depicts
*/
public List<String> getSelectedExistingDepictions() {
return selectedExistingDepictions;
}
/**
* Initialize existing depicts
*
* @param selectedExistingDepictions existing depicts
*/
public void setSelectedExistingDepictions(final List<String> selectedExistingDepictions) {
this.selectedExistingDepictions = selectedExistingDepictions;
}
}

View file

@ -0,0 +1,242 @@
package fr.free.nrw.commons.upload
import android.content.Context
import android.net.Uri
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.FileUtils.getSHA1
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import java.util.Date
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class UploadModel @Inject internal constructor(
@param:Named("licenses") val licenses: List<String>,
@param:Named("default_preferences") val store: JsonKvStore,
@param:Named("licenses_by_name") val licensesByName: Map<String, String>,
val context: Context,
val sessionManager: SessionManager,
val fileProcessor: FileProcessor,
val imageProcessingService: ImageProcessingService
) {
var license: String? = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3)
val items: MutableList<UploadItem> = mutableListOf()
val compositeDisposable: CompositeDisposable = CompositeDisposable()
val selectedCategories: MutableList<String> = mutableListOf()
val selectedDepictions: MutableList<DepictedItem> = mutableListOf()
/**
* Existing depicts which are selected
*/
var selectedExistingDepictions: MutableList<String> = mutableListOf()
val count: Int
get() = items.size
val uploads: List<UploadItem>
get() = items
var selectedLicense: String?
get() = license
set(licenseName) {
license = licensesByName[licenseName]
if (license == null) {
store.remove(Prefs.DEFAULT_LICENSE)
} else {
store.putString(Prefs.DEFAULT_LICENSE, license!!)
}
}
/**
* cleanup the resources, I am Singleton, preparing for fresh upload
*/
fun cleanUp() {
compositeDisposable.clear()
fileProcessor.cleanup()
items.clear()
selectedCategories.clear()
selectedDepictions.clear()
selectedExistingDepictions.clear()
}
fun setSelectedCategories(categories: List<String>) {
selectedCategories.clear()
selectedCategories.addAll(categories)
}
/**
* pre process a one item at a time
*/
fun preProcessImage(
uploadableFile: UploadableFile?,
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): Observable<UploadItem> = Observable.just(
createAndAddUploadItem(uploadableFile, place, similarImageInterface, inAppPictureLocation)
)
/**
* Calls validateImage() of ImageProcessingService to check quality of image
*
* @param uploadItem UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @return Quality of UploadItem
*/
fun getImageQuality(uploadItem: UploadItem, inAppPictureLocation: LatLng?): Single<Int> =
imageProcessingService.validateImage(uploadItem, inAppPictureLocation)
/**
* Calls checkDuplicateImage() of ImageProcessingService to check if image is duplicate
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String?): Single<Int> =
imageProcessingService.checkDuplicateImage(filePath)
/**
* Calls validateCaption() of ImageProcessingService to check caption of image
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
fun getCaptionQuality(uploadItem: UploadItem): Single<Int> =
imageProcessingService.validateCaption(uploadItem)
private fun createAndAddUploadItem(
uploadableFile: UploadableFile?,
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): UploadItem {
val dateTimeWithSource = uploadableFile?.getFileCreatedDate(context)
var fileCreatedDate: Long = -1
var createdTimestampSource = ""
var fileCreatedDateString: String? = ""
if (dateTimeWithSource != null) {
fileCreatedDate = dateTimeWithSource.epochDate
fileCreatedDateString = dateTimeWithSource.dateString
createdTimestampSource = dateTimeWithSource.source
}
Timber.d("File created date is %d", fileCreatedDate)
val imageCoordinates = fileProcessor
.processFileCoordinates(
similarImageInterface, uploadableFile?.getFilePath(),
inAppPictureLocation
)
val uploadItem = UploadItem(
Uri.parse(uploadableFile?.getFilePath()),
uploadableFile?.getMimeType(context), imageCoordinates, place, fileCreatedDate,
createdTimestampSource,
uploadableFile?.contentUri,
fileCreatedDateString
)
// If an uploadItem of the same uploadableFile has been created before, we return that.
// This is to avoid multiple instances of uploadItem of same file passed around.
if (items.contains(uploadItem)) {
return items[items.indexOf(uploadItem)]
}
uploadItem.uploadMediaDetails[0] = UploadMediaDetail(place)
if (!items.contains(uploadItem)) {
items.add(uploadItem)
}
return uploadItem
}
fun buildContributions(): Observable<Contribution> {
return Observable.fromIterable(items).map { item: UploadItem ->
val imageSHA1 = getSHA1(
context.contentResolver.openInputStream(item.contentUri!!)!!
)
val contribution = Contribution(
item,
sessionManager,
buildList { addAll(selectedDepictions) },
buildList { addAll(selectedCategories) },
imageSHA1
)
contribution.setHasInvalidLocation(item.hasInvalidLocation())
Timber.d(
"Created timestamp while building contribution is %s, %s",
item.createdTimestamp,
item.createdTimestamp?.let { Date(it) }
)
if (item.createdTimestamp != -1L) {
contribution.dateCreated = item.createdTimestamp?.let { Date(it) }
contribution.dateCreatedSource = item.createdTimestampSource
//Set the date only if you have it, else the upload service is gonna try it the other way
}
if (contribution.wikidataPlace != null) {
contribution.wikidataPlace!!.isMonumentUpload = item.isWLMUpload
}
contribution.countryCode = item.countryCode
contribution
}
}
fun deletePicture(filePath: String) {
val iterator = items.iterator()
while (iterator.hasNext()) {
if (iterator.next().mediaUri.toString().contains(filePath)) {
iterator.remove()
break
}
}
if (items.isEmpty()) {
cleanUp()
}
}
fun onDepictItemClicked(depictedItem: DepictedItem, media: Media?) {
if (media == null) {
if (depictedItem.isSelected) {
selectedDepictions.add(depictedItem)
} else {
selectedDepictions.remove(depictedItem)
}
} else {
if (depictedItem.isSelected) {
if (media.depictionIds.contains(depictedItem.id)) {
selectedExistingDepictions.add(depictedItem.id)
} else {
selectedDepictions.add(depictedItem)
}
} else {
if (media.depictionIds.contains(depictedItem.id)) {
selectedExistingDepictions.remove(depictedItem.id)
if (!media.depictionIds.contains(depictedItem.id)) {
media.depictionIds = mutableListOf<String>().apply {
add(depictedItem.id)
addAll(media.depictionIds)
}
}
} else {
selectedDepictions.remove(depictedItem)
}
}
}
}
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int) {
fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates)
items[uploadItemIndex].gpsCoords = imageCoordinates
}
}

View file

@ -1,10 +1,10 @@
package fr.free.nrw.commons.upload
import android.annotation.SuppressLint
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.CommonsApplication.Companion.IS_LIMITED_CONNECTION_MODE_ENABLED
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract
@ -34,6 +34,8 @@ class UploadPresenter @Inject internal constructor(
private val compositeDisposable = CompositeDisposable()
lateinit var basicKvStoreFactory: (String) -> BasicKvStore
/**
* Called by the submit button in [UploadActivity]
*/
@ -69,8 +71,7 @@ class UploadPresenter @Inject internal constructor(
private fun processContributionsForSubmission() {
if (view.isLoggedIn()) {
view.showProgress(true)
repository.buildContributions()
?.observeOn(Schedulers.io())
repository.buildContributions().observeOn(Schedulers.io())
?.subscribe(object : Observer<Contribution> {
override fun onSubscribe(d: Disposable) {
view.showProgress(false)
@ -127,14 +128,20 @@ class UploadPresenter @Inject internal constructor(
}
}
override fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore) {
basicKvStoreFactory = factory
}
/**
* Calls checkImageQuality of UploadMediaPresenter to check image quality of next image
*
* @param uploadItemIndex Index of next image, whose quality is to be checked
*/
override fun checkImageQuality(uploadItemIndex: Int) {
val uploadItem = repository.getUploadItem(uploadItemIndex)
presenter.checkImageQuality(uploadItem, uploadItemIndex)
repository.getUploadItem(uploadItemIndex)?.let {
presenter.setupBasicKvStoreFactory(basicKvStoreFactory)
presenter.checkImageQuality(it, uploadItemIndex)
}
}
override fun deletePictureAtIndex(index: Int) {
@ -156,8 +163,9 @@ class UploadPresenter @Inject internal constructor(
view.onUploadMediaDeleted(index)
if (index != uploadableFiles.size && index != 0) {
// if the deleted image was not the last item to be uploaded, check quality of next
val uploadItem = repository.getUploadItem(index)
presenter.checkImageQuality(uploadItem, index)
repository.getUploadItem(index)?.let {
presenter.checkImageQuality(it, index)
}
}
if (uploadableFiles.size < 2) {

View file

@ -140,7 +140,7 @@ class CategoriesPresenter
*/
private fun getImageTitleList(): List<String> =
repository.getUploads()
.map { it.uploadMediaDetails[0].captionText }
.map { it.uploadMediaDetails[0].captionText!! }
.filterNot { TextUtils.isEmpty(it) }
/**

View file

@ -95,12 +95,10 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
if (media == null) {
if (callback != null) {
binding!!.tvTitle.text = getString(
R.string.step_count, callback!!.getIndexInViewFlipper(
this
) + 1,
callback!!.totalNumberOfSteps, getString(R.string.categories_activity_title)
)
binding!!.tvTitle.text = getString(R.string.step_count,
callback!!.getIndexInViewFlipper(this) + 1,
callback!!.totalNumberOfSteps,
getString(R.string.categories_activity_title))
}
} else {
binding!!.tvTitle.setText(R.string.edit_categories)
@ -220,7 +218,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
override fun goToNextScreen() {
callback!!.onNextButtonClicked(callback!!.getIndexInViewFlipper(this))
callback?.let { it.onNextButtonClicked(it.getIndexInViewFlipper(this)) }
}
override fun showNoCategorySelected() {
@ -322,7 +320,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
mediaDetailFragment.onResume()
goBackToPreviousScreen()
} else {
callback!!.onPreviousButtonClicked(callback!!.getIndexInViewFlipper(this))
callback?.let { it.onPreviousButtonClicked(it.getIndexInViewFlipper(this)) }
}
}

View file

@ -96,11 +96,10 @@ class DepictsFragment : UploadBaseFragment(), DepictsContract.View {
if (media == null) {
binding.depictsTitle.text =
String.format(
getString(R.string.step_count), callback!!.getIndexInViewFlipper(
this
) + 1,
callback!!.totalNumberOfSteps, getString(R.string.depicts_step_title)
String.format(getString(R.string.step_count),
callback!!.getIndexInViewFlipper(this) + 1,
callback!!.totalNumberOfSteps,
getString(R.string.depicts_step_title)
)
} else {
binding.depictsTitle.setText(R.string.edit_depictions)

View file

@ -45,8 +45,7 @@ class MediaLicenseFragment : UploadBaseFragment(), MediaLicenseContract.View {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvTitle.text = getString(
R.string.step_count,
binding.tvTitle.text = getString(R.string.step_count,
callback!!.getIndexInViewFlipper(this) + 1,
callback!!.totalNumberOfSteps,
getString(R.string.license_step_title)

View file

@ -1,922 +0,0 @@
package fr.free.nrw.commons.upload.mediaDetails;
import static android.app.Activity.RESULT_OK;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.speech.RecognizerIntent;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import androidx.recyclerview.widget.LinearLayoutManager;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.locationpicker.LocationPicker;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.databinding.FragmentUploadMediaDetailFragmentBinding;
import fr.free.nrw.commons.edit.EditActivity;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.ImageCoordinates;
import fr.free.nrw.commons.upload.SimilarImageDialogFragment;
import fr.free.nrw.commons.upload.UploadActivity;
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;
import fr.free.nrw.commons.utils.ViewUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class UploadMediaDetailFragment extends UploadBaseFragment implements
UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener {
private UploadMediaDetailAdapter uploadMediaDetailAdapter;
private final ActivityResultLauncher<Intent> startForResult = registerForActivityResult(
new StartActivityForResult(), result -> {
onCameraPosition(result);
});
private final ActivityResultLauncher<Intent> startForEditActivityResult = registerForActivityResult(
new StartActivityForResult(), result -> {
onEditActivityResult(result);
}
);
private final ActivityResultLauncher<Intent> voiceInputResultLauncher = registerForActivityResult(
new StartActivityForResult(), result -> {
onVoiceInput(result);
}
);
public static Activity activity ;
private int indexOfFragment;
/**
* A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex.
* 12.3433,54.78897 from applicationKvStore.
*/
public static final String LAST_LOCATION = "last_location_while_uploading";
public static final String LAST_ZOOM = "last_zoom_level_while_uploading";
public static final String UPLOADABLE_FILE = "uploadable_file";
public static final String UPLOAD_MEDIA_DETAILS = "upload_media_detail_adapter";
/**
* True when user removes location from the current image
*/
private boolean hasUserRemovedLocation;
@Inject
UploadMediaDetailsContract.UserActionListener presenter;
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
@Inject
RecentLanguagesDao recentLanguagesDao;
private UploadableFile uploadableFile;
private Place place;
private boolean isExpanded = true;
/**
* True if location is added via the "missing location" popup dialog (which appears after
* tapping "Next" if the picture has no geographical coordinates).
*/
private boolean isMissingLocationDialog;
/**
* showNearbyFound will be true, if any nearby location found that needs pictures and the nearby
* popup is yet to be shown Used to show and check if the nearby found popup is already shown
*/
private boolean showNearbyFound;
/**
* nearbyPlace holds the detail of nearby place that need pictures, if any found
*/
private Place nearbyPlace;
private UploadItem uploadItem;
/**
* inAppPictureLocation: use location recorded while using the in-app camera if device camera
* does not record it in the EXIF
*/
private LatLng inAppPictureLocation;
/**
* editableUploadItem : Storing the upload item before going to update the coordinates
*/
private UploadItem editableUploadItem;
private BasicKvStore basicKvStore;
private final String keyForShowingAlertDialog = "isNoNetworkAlertDialogShowing";
private UploadMediaDetailFragmentCallback callback;
private FragmentUploadMediaDetailFragmentBinding binding;
public void setCallback(UploadMediaDetailFragmentCallback callback) {
this.callback = callback;
UploadMediaPresenter.presenterCallback = callback;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if(savedInstanceState!=null && uploadableFile==null) {
uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE);
}
}
public void setImageToBeUploaded(UploadableFile uploadableFile, Place place,
LatLng inAppPictureLocation) {
this.uploadableFile = uploadableFile;
this.place = place;
this.inAppPictureLocation = inAppPictureLocation;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentUploadMediaDetailFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
activity = getActivity();
basicKvStore = new BasicKvStore(activity, "CurrentUploadImageQualities");
if (callback != null) {
indexOfFragment = callback.getIndexInViewFlipper(this);
init();
}
if(savedInstanceState!=null){
if(uploadMediaDetailAdapter.getItems().size()==0 && callback != null){
uploadMediaDetailAdapter.setItems(savedInstanceState.getParcelableArrayList(UPLOAD_MEDIA_DETAILS));
presenter.setUploadMediaDetails(uploadMediaDetailAdapter.getItems(),
indexOfFragment);
}
}
try {
if(!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, getActivity())) {
ActivityUtils.startActivityWithFlags(
getActivity(), MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
} catch (Exception e) {
}
}
private void init() {
if (binding == null) {
return;
}
binding.tvTitle.setText(getString(R.string.step_count, (indexOfFragment + 1),
callback.getTotalNumberOfSteps(), getString(R.string.media_detail_step_title)));
binding.tooltip.setOnClickListener(
v -> showInfoAlert(R.string.media_detail_step_title, R.string.media_details_tooltip));
initPresenter();
presenter.receiveImage(uploadableFile, place, inAppPictureLocation);
initRecyclerView();
if (indexOfFragment == 0) {
binding.btnPrevious.setEnabled(false);
binding.btnPrevious.setAlpha(0.5f);
} else {
binding.btnPrevious.setEnabled(true);
binding.btnPrevious.setAlpha(1.0f);
}
// If the image EXIF data contains the location, show the map icon with a green tick
if (inAppPictureLocation != null ||
(uploadableFile != null && uploadableFile.hasLocation())) {
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
} else {
// Otherwise, show the map icon with a red question mark
Drawable mapQuestionMark =
getResources().getDrawable(R.drawable.ic_map_not_available_20dp);
binding.locationImageView.setImageDrawable(mapQuestionMark);
binding.locationTextView.setText(R.string.add_location);
}
//If this is the last media, we have nothing to copy, lets not show the button
if (indexOfFragment == callback.getTotalNumberOfSteps() - 4) {
binding.btnCopySubsequentMedia.setVisibility(View.GONE);
} else {
binding.btnCopySubsequentMedia.setVisibility(View.VISIBLE);
}
binding.btnNext.setOnClickListener(v -> onNextButtonClicked());
binding.btnPrevious.setOnClickListener(v -> onPreviousButtonClicked());
binding.llEditImage.setOnClickListener(v -> onEditButtonClicked());
binding.llContainerTitle.setOnClickListener(v -> onLlContainerTitleClicked());
binding.llLocationStatus.setOnClickListener(v -> onIbMapClicked());
binding.btnCopySubsequentMedia.setOnClickListener(v -> onButtonCopyTitleDescToSubsequentMedia());
attachImageViewScaleChangeListener();
}
/**
* Attaches the scale change listener to the image view
*/
private void attachImageViewScaleChangeListener() {
binding.backgroundImage.setOnScaleChangeListener(
(scaleFactor, focusX, focusY) -> {
//Whenever the uses plays with the image, lets collapse the media detail container
//only if it is not already collapsed, which resolves flickering of arrow
if (isExpanded) {
expandCollapseLlMediaDetail(false);
}
});
}
/**
* attach the presenter with the view
*/
private void initPresenter() {
presenter.onAttachView(this);
}
/**
* init the description recycler veiw and caption recyclerview
*/
private void initRecyclerView() {
uploadMediaDetailAdapter = new UploadMediaDetailAdapter(this,
defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao, voiceInputResultLauncher);
uploadMediaDetailAdapter.setCallback(this::showInfoAlert);
uploadMediaDetailAdapter.setEventListener(this);
binding.rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext()));
binding.rvDescriptions.setAdapter(uploadMediaDetailAdapter);
}
/**
* show dialog with info
* @param titleStringID
* @param messageStringId
*/
private void showInfoAlert(int titleStringID, int messageStringId) {
DialogUtil.showAlertDialog(getActivity(), getString(titleStringID),
getString(messageStringId), getString(android.R.string.ok), null);
}
public void onNextButtonClicked() {
if (callback == null) {
return;
}
presenter.displayLocDialog(indexOfFragment, inAppPictureLocation, hasUserRemovedLocation);
}
public void onPreviousButtonClicked() {
if (callback == null) {
return;
}
callback.onPreviousButtonClicked(indexOfFragment);
}
public void onEditButtonClicked() {
presenter.onEditButtonClicked(indexOfFragment);
}
@Override
public void showSimilarImageFragment(String originalFilePath, String possibleFilePath,
ImageCoordinates similarImageCoordinates) {
BasicKvStore basicKvStore = new BasicKvStore(getActivity(), "IsAnyImageCancelled");
if (!basicKvStore.getBoolean("IsAnyImageCancelled", false)) {
SimilarImageDialogFragment newFragment = new SimilarImageDialogFragment();
newFragment.setCancelable(false);
newFragment.setCallback(new SimilarImageDialogFragment.Callback() {
@Override
public void onPositiveResponse() {
Timber.d("positive response from similar image fragment");
presenter.useSimilarPictureCoordinates(similarImageCoordinates,
indexOfFragment);
// set the description text when user selects to use coordinate from the other image
// which was taken within 120s
// fixing: https://github.com/commons-app/apps-android-commons/issues/4700
uploadMediaDetailAdapter.getItems().get(0).setDescriptionText(
getString(R.string.similar_coordinate_description_auto_set));
updateMediaDetails(uploadMediaDetailAdapter.getItems());
// Replace the 'Add location' button with 'Edit location' button when user clicks
// yes in similar image dialog
// fixing: https://github.com/commons-app/apps-android-commons/issues/5669
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
}
@Override
public void onNegativeResponse() {
Timber.d("negative response from similar image fragment");
}
});
Bundle args = new Bundle();
args.putString("originalImagePath", originalFilePath);
args.putString("possibleImagePath", possibleFilePath);
newFragment.setArguments(args);
newFragment.show(getChildFragmentManager(), "dialog");
}
}
@Override
public void onImageProcessed(UploadItem uploadItem, Place place) {
if (binding == null) {
return;
}
binding.backgroundImage.setImageURI(uploadItem.getMediaUri());
}
/**
* Sets variables to Show popup if any nearby location needing pictures matches uploadable picture's GPS location
* @param uploadItem
* @param place
*/
@Override
public void onNearbyPlaceFound(UploadItem uploadItem, Place place) {
nearbyPlace = place;
this.uploadItem = uploadItem;
showNearbyFound = true;
if (callback == null) {
return;
}
if (indexOfFragment == 0) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) {
if (callback != null) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
}
}
} else {
showNearbyPlaceFound(nearbyPlace);
}
showNearbyFound = false;
}
}
/**
* Shows nearby place found popup
* @param place
*/
@SuppressLint("StringFormatInvalid")
// To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format
private void showNearbyPlaceFound(Place place) {
final View customLayout = getLayoutInflater().inflate(R.layout.custom_nearby_found, null);
ImageView nearbyFoundImage = customLayout.findViewById(R.id.nearbyItemImage);
nearbyFoundImage.setImageURI(uploadItem.getMediaUri());
final Activity activity = getActivity();
if (activity instanceof UploadActivity) {
final boolean isMultipleFilesSelected = ((UploadActivity) activity).getIsMultipleFilesSelected();
// Determine the message based on the selection status
String message;
if (isMultipleFilesSelected) {
// Use plural message if multiple files are selected
message = String.format(Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_plural),
place.getName());
} else {
// Use singular message if only one file is selected
message = String.format(Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_singular),
place.getName());
}
// Show the AlertDialog with the determined message
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_nearby_place_found_title),
message,
() -> {
// Execute when user confirms the upload is of the specified place
UploadActivity.nearbyPopupAnswers.put(place, true);
presenter.onUserConfirmedUploadIsOfPlace(place, indexOfFragment);
},
() -> {
// Execute when user cancels the upload of the specified place
UploadActivity.nearbyPopupAnswers.put(place, false);
},
customLayout
);
}
}
@Override
public void showProgress(boolean shouldShow) {
if (callback == null) {
return;
}
callback.showProgress(shouldShow);
}
@Override
public void onImageValidationSuccess() {
if (callback == null) {
return;
}
callback.onNextButtonClicked(indexOfFragment);
}
/**
* This method gets called whenever the next/previous button is pressed
*/
@Override
protected void onBecameVisible() {
super.onBecameVisible();
if (callback == null) {
return;
}
presenter.fetchTitleAndDescription(indexOfFragment);
if (showNearbyFound) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
}
} else {
showNearbyPlaceFound(nearbyPlace);
}
showNearbyFound = false;
}
}
@Override
public void showMessage(int stringResourceId, int colorResourceId) {
ViewUtil.showLongToast(getContext(), stringResourceId);
}
@Override
public void showMessage(String message, int colorResourceId) {
ViewUtil.showLongToast(getContext(), message);
}
@Override
public void showDuplicatePicturePopup(UploadItem uploadItem) {
if (defaultKvStore.getBoolean("showDuplicatePicturePopup", true)) {
String uploadTitleFormat = getString(R.string.upload_title_duplicate);
View checkBoxView = View
.inflate(getActivity(), R.layout.nearby_permission_dialog, null);
CheckBox checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again);
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
defaultKvStore.putBoolean("showDuplicatePicturePopup", false);
}
});
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.duplicate_file_name),
String.format(Locale.getDefault(),
uploadTitleFormat,
uploadItem.getFilename()),
getString(R.string.upload),
getString(R.string.cancel),
() -> {
uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP);
onImageValidationSuccess();
}, null,
checkBoxView);
} else {
uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP);
onImageValidationSuccess();
}
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Does nothing if there is network connectivity and then the user presses okay
*/
@Override
public void showConnectionErrorPopupForCaptionCheck() {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
() -> {
if (!NetworkUtils.isInternetConnectionEstablished(activity)) {
showConnectionErrorPopupForCaptionCheck();
}
},
() -> {
activity.finish();
});
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Recalls UploadMediaPresenter.getImageQuality for all the next upload items,
* if there is network connectivity and then the user presses okay
*/
@Override
public void showConnectionErrorPopup() {
try {
boolean FLAG_ALERT_DIALOG_SHOWING = basicKvStore.getBoolean(
keyForShowingAlertDialog, false);
if (!FLAG_ALERT_DIALOG_SHOWING) {
basicKvStore.putBoolean(keyForShowingAlertDialog, true);
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
() -> {
basicKvStore.putBoolean(keyForShowingAlertDialog, false);
if (NetworkUtils.isInternetConnectionEstablished(activity)) {
int sizeOfUploads = basicKvStore.getInt(
UploadActivity.keyForCurrentUploadImagesSize);
for (int i = indexOfFragment; i < sizeOfUploads; i++) {
presenter.getImageQuality(i, inAppPictureLocation, activity);
}
} else {
showConnectionErrorPopup();
}
},
() -> {
basicKvStore.putBoolean(keyForShowingAlertDialog, false);
activity.finish();
},
null
);
}
} catch (Exception e) {
}
}
@Override
public void showExternalMap(final UploadItem uploadItem) {
goToLocationPickerActivity(uploadItem);
}
/**
* Launches the image editing activity to edit the specified UploadItem.
*
* @param uploadItem The UploadItem to be edited.
*
* This method is called to start the image editing activity for a specific UploadItem.
* It sets the UploadItem as the currently editable item, creates an intent to launch the
* EditActivity, and passes the image file path as an extra in the intent. The activity
* is started using resultLauncher that handles the result in respective callback.
*/
@Override
public void showEditActivity(UploadItem uploadItem) {
editableUploadItem = uploadItem;
Intent intent = new Intent(getContext(), EditActivity.class);
intent.putExtra("image", uploadableFile.getFilePath().toString());
startForEditActivityResult.launch(intent);
}
/**
* Start Location picker activity. Show the location first then user can modify it by clicking
* modify location button.
* @param uploadItem current upload item
*/
private void goToLocationPickerActivity(final UploadItem uploadItem) {
editableUploadItem = uploadItem;
double defaultLatitude = 37.773972;
double defaultLongitude = -122.431297;
double defaultZoom = 16.0;
final Intent locationPickerIntent;
/* Retrieve image location from EXIF if present or
check if user has provided location while using the in-app camera.
Use location of last UploadItem if none of them is available */
if (uploadItem.getGpsCoords() != null && uploadItem.getGpsCoords()
.getDecLatitude() != 0.0 && uploadItem.getGpsCoords().getDecLongitude() != 0.0) {
defaultLatitude = uploadItem.getGpsCoords()
.getDecLatitude();
defaultLongitude = uploadItem.getGpsCoords().getDecLongitude();
defaultZoom = uploadItem.getGpsCoords().getZoomLevel();
locationPickerIntent = new LocationPicker.IntentBuilder()
.defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom))
.activityKey("UploadActivity")
.build(getActivity());
} else {
if (defaultKvStore.getString(LAST_LOCATION) != null) {
final String[] locationLatLng
= defaultKvStore.getString(LAST_LOCATION).split(",");
defaultLatitude = Double.parseDouble(locationLatLng[0]);
defaultLongitude = Double.parseDouble(locationLatLng[1]);
}
if (defaultKvStore.getString(LAST_ZOOM) != null) {
defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM));
}
locationPickerIntent = new LocationPicker.IntentBuilder()
.defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom))
.activityKey("NoLocationUploadActivity")
.build(getActivity());
}
startForResult.launch(locationPickerIntent);
}
private void onCameraPosition(ActivityResult result){
if (result.getResultCode() == RESULT_OK) {
assert result.getData() != null;
final CameraPosition cameraPosition = LocationPicker.getCameraPosition(result.getData());
if (cameraPosition != null) {
final String latitude = String.valueOf(cameraPosition.getLatitude());
final String longitude = String.valueOf(cameraPosition.getLongitude());
final double zoom = cameraPosition.getZoom();
editLocation(latitude, longitude, zoom);
// If isMissingLocationDialog is true, it means that the user has already tapped the
// "Next" button, so go directly to the next step.
if (isMissingLocationDialog) {
isMissingLocationDialog = false;
onNextButtonClicked();
}
} else {
// If camera position is null means location is removed by the user
removeLocation();
}
}
}
private void onVoiceInput(ActivityResult result) {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
ArrayList<String> resultData = result.getData().getStringArrayListExtra(
RecognizerIntent.EXTRA_RESULTS);
uploadMediaDetailAdapter.handleSpeechResult(resultData.get(0));
}else {
Timber.e("Error %s", result.getResultCode());
}
}
private void onEditActivityResult(ActivityResult result){
if (result.getResultCode() == RESULT_OK) {
String path = result.getData().getStringExtra("editedImageFilePath");
if (Objects.equals(result, "Error")) {
Timber.e("Error in rotating image");
return;
}
try {
if (binding != null){
binding.backgroundImage.setImageURI(Uri.fromFile(new File(path)));
}
editableUploadItem.setContentAndMediaUri(Uri.fromFile(new File(path)));
callback.changeThumbnail(indexOfFragment,
path);
} catch (Exception e) {
Timber.e(e);
}
}
}
/**
* Removes the location data from the image, by setting them to null
*/
public void removeLocation() {
editableUploadItem.getGpsCoords().setDecimalCoords(null);
try {
ExifInterface sourceExif = new ExifInterface(uploadableFile.getFilePath());
String[] exifTags = {
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
};
for (String tag : exifTags) {
sourceExif.setAttribute(tag, null);
}
sourceExif.saveAttributes();
Drawable mapQuestion = getResources().getDrawable(R.drawable.ic_map_not_available_20dp);
if (binding != null) {
binding.locationImageView.setImageDrawable(mapQuestion);
binding.locationTextView.setText(R.string.add_location);
}
editableUploadItem.getGpsCoords().setDecLatitude(0.0);
editableUploadItem.getGpsCoords().setDecLongitude(0.0);
editableUploadItem.getGpsCoords().setImageCoordsExists(false);
hasUserRemovedLocation = true;
Toast.makeText(getContext(), getString(R.string.location_removed), Toast.LENGTH_LONG)
.show();
} catch (Exception e) {
Timber.d(e);
Toast.makeText(getContext(), "Location could not be removed due to internal error",
Toast.LENGTH_LONG).show();
}
}
/**
* Update the old coordinates with new one
* @param latitude new latitude
* @param longitude new longitude
*/
public void editLocation(final String latitude, final String longitude, final double zoom) {
editableUploadItem.getGpsCoords().setDecLatitude(Double.parseDouble(latitude));
editableUploadItem.getGpsCoords().setDecLongitude(Double.parseDouble(longitude));
editableUploadItem.getGpsCoords().setDecimalCoords(latitude + "|" + longitude);
editableUploadItem.getGpsCoords().setImageCoordsExists(true);
editableUploadItem.getGpsCoords().setZoomLevel(zoom);
// Replace the map icon using the one with a green tick
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
if (binding != null) {
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
}
Toast.makeText(getContext(), getString(R.string.location_updated), Toast.LENGTH_LONG).show();
}
@Override
public void updateMediaDetails(List<UploadMediaDetail> uploadMediaDetails) {
uploadMediaDetailAdapter.setItems(uploadMediaDetails);
showNearbyFound =
showNearbyFound && (
uploadMediaDetails == null || uploadMediaDetails.isEmpty()
|| listContainsEmptyDetails(
uploadMediaDetails));
}
/**
* if the media details that come in here are empty
* (empty caption AND empty description, with caption being the decider here)
* this method allows usage of nearby place caption and description if any
* else it takes the media details saved in prior for this picture
* @param uploadMediaDetails saved media details,
* ex: in case when "copy to subsequent media" button is clicked
* for a previous image
* @return boolean whether the details are empty or not
*/
private boolean listContainsEmptyDetails(List<UploadMediaDetail> uploadMediaDetails) {
for (UploadMediaDetail uploadDetail: uploadMediaDetails) {
if (!TextUtils.isEmpty(uploadDetail.getCaptionText()) && !TextUtils.isEmpty(uploadDetail.getDescriptionText())) {
return false;
}
}
return true;
}
/**
* Showing dialog for adding location
*
* @param onSkipClicked proceed for verifying image quality
*/
@Override
public void displayAddLocationDialog(final Runnable onSkipClicked) {
isMissingLocationDialog = true;
DialogUtil.showAlertDialog(requireActivity(),
getString(R.string.no_location_found_title),
getString(R.string.no_location_found_message),
getString(R.string.add_location),
getString(R.string.skip_login),
this::onIbMapClicked,
onSkipClicked);
}
@Override
public void onDestroyView() {
super.onDestroyView();
presenter.onDetachView();
}
public void onLlContainerTitleClicked() {
expandCollapseLlMediaDetail(!isExpanded);
}
/**
* show hide media detail based on
* @param shouldExpand
*/
private void expandCollapseLlMediaDetail(boolean shouldExpand){
if (binding == null) {
return;
}
binding.llContainerMediaDetail.setVisibility(shouldExpand ? View.VISIBLE : View.GONE);
isExpanded = !isExpanded;
binding.ibExpandCollapse.setRotation(binding.ibExpandCollapse.getRotation() + 180);
}
public void onIbMapClicked() {
if (callback == null) {
return;
}
presenter.onMapIconClicked(indexOfFragment);
}
@Override
public void onPrimaryCaptionTextChange(boolean isNotEmpty) {
if (binding == null) {
return;
}
binding.btnCopySubsequentMedia.setEnabled(isNotEmpty);
binding.btnCopySubsequentMedia.setClickable(isNotEmpty);
binding.btnCopySubsequentMedia.setAlpha(isNotEmpty ? 1.0f : 0.5f);
binding.btnNext.setEnabled(isNotEmpty);
binding.btnNext.setClickable(isNotEmpty);
binding.btnNext.setAlpha(isNotEmpty ? 1.0f : 0.5f);
}
/**
* Adds new language item to RecyclerView
*/
@Override
public void addLanguage() {
UploadMediaDetail uploadMediaDetail = new UploadMediaDetail();
uploadMediaDetail.setManuallyAdded(true);//This was manually added by the user
uploadMediaDetailAdapter.addDescription(uploadMediaDetail);
binding.rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.getItemCount()-1);
}
public interface UploadMediaDetailFragmentCallback extends Callback {
void deletePictureAtIndex(int index);
void changeThumbnail(int index, String uri);
}
public void onButtonCopyTitleDescToSubsequentMedia(){
presenter.copyTitleAndDescriptionToSubsequentMedia(indexOfFragment);
Toast.makeText(getContext(), getResources().getString(R.string.copied_successfully), Toast.LENGTH_SHORT).show();
}
@Override
public void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
if(uploadableFile!=null){
outState.putParcelable(UPLOADABLE_FILE,uploadableFile);
}
if(uploadMediaDetailAdapter!=null){
outState.putParcelableArrayList(UPLOAD_MEDIA_DETAILS,
(ArrayList<? extends Parcelable>) uploadMediaDetailAdapter.getItems());
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,903 @@
package fr.free.nrw.commons.upload.mediaDetails
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.speech.RecognizerIntent
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.CompoundButton
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.exifinterface.media.ExifInterface
import androidx.recyclerview.widget.LinearLayoutManager
import fr.free.nrw.commons.CameraPosition
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.databinding.FragmentUploadMediaDetailFragmentBinding
import fr.free.nrw.commons.edit.EditActivity
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.locationpicker.LocationPicker
import fr.free.nrw.commons.locationpicker.LocationPicker.getCameraPosition
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.SimilarImageDialogFragment
import fr.free.nrw.commons.upload.UploadActivity
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.upload.mediaDetails.UploadMediaPresenter.Companion.presenterCallback
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.ImageUtils
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK
import fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult
import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import timber.log.Timber
import java.io.File
import java.util.ArrayList
import java.util.Locale
import java.util.Objects
import javax.inject.Inject
import javax.inject.Named
class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContract.View,
UploadMediaDetailAdapter.EventListener {
private val startForResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onCameraPosition)
private val startForEditActivityResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onEditActivityResult)
private val voiceInputResultLauncher = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onVoiceInput)
@Inject
lateinit var presenter: UploadMediaDetailsContract.UserActionListener
@Inject
@field:Named("default_preferences")
lateinit var defaultKvStore: JsonKvStore
@Inject
lateinit var recentLanguagesDao: RecentLanguagesDao
/**
* True when user removes location from the current image
*/
var hasUserRemovedLocation = false
/**
* True if location is added via the "missing location" popup dialog (which appears after
* tapping "Next" if the picture has no geographical coordinates).
*/
private var isMissingLocationDialog = false
/**
* showNearbyFound will be true, if any nearby location found that needs pictures and the nearby
* popup is yet to be shown Used to show and check if the nearby found popup is already shown
*/
private var showNearbyFound = false
/**
* nearbyPlace holds the detail of nearby place that need pictures, if any found
*/
private var nearbyPlace: Place? = null
private var uploadItem: UploadItem? = null
/**
* inAppPictureLocation: use location recorded while using the in-app camera if device camera
* does not record it in the EXIF
*/
var inAppPictureLocation: LatLng? = null
/**
* editableUploadItem : Storing the upload item before going to update the coordinates
*/
private var editableUploadItem: UploadItem? = null
private var _binding: FragmentUploadMediaDetailFragmentBinding? = null
private val binding: FragmentUploadMediaDetailFragmentBinding get() = _binding!!
private var basicKvStore: BasicKvStore? = null
private val keyForShowingAlertDialog = "isNoNetworkAlertDialogShowing"
private var uploadableFile: UploadableFile? = null
private var place: Place? = null
private lateinit var uploadMediaDetailAdapter: UploadMediaDetailAdapter
var indexOfFragment = 0
var isExpanded = true
var fragmentCallback: UploadMediaDetailFragmentCallback? = null
set(value) {
field = value
UploadMediaPresenter.presenterCallback = value
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null && uploadableFile == null) {
uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE)
}
}
fun setImageToBeUploaded(
uploadableFile: UploadableFile?, place: Place?, inAppPictureLocation: LatLng?
) {
this.uploadableFile = uploadableFile
this.place = place
this.inAppPictureLocation = inAppPictureLocation
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentUploadMediaDetailFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
basicKvStore = BasicKvStore(requireActivity(), "CurrentUploadImageQualities")
if (fragmentCallback != null) {
indexOfFragment = fragmentCallback!!.getIndexInViewFlipper(this)
initializeFragment()
}
if (savedInstanceState != null) {
if (uploadMediaDetailAdapter.items.isEmpty() && fragmentCallback != null) {
uploadMediaDetailAdapter.items = savedInstanceState.getParcelableArrayList(UPLOAD_MEDIA_DETAILS)!!
presenter.setUploadMediaDetails(uploadMediaDetailAdapter.items, indexOfFragment)
}
}
try {
if (!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, requireActivity())) {
startActivityWithFlags(
requireActivity(),
MainActivity::class.java,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP
)
}
} catch (_: Exception) {
}
}
private fun initializeFragment() {
if (_binding == null) {
return
}
binding.tvTitle.text = getString(
R.string.step_count, (indexOfFragment + 1),
fragmentCallback!!.totalNumberOfSteps, getString(R.string.media_detail_step_title)
)
binding.tooltip.setOnClickListener {
showInfoAlert(
R.string.media_detail_step_title,
R.string.media_details_tooltip
)
}
presenter.onAttachView(this)
presenter.setupBasicKvStoreFactory { BasicKvStore(requireActivity(), it) }
presenter.receiveImage(uploadableFile, place, inAppPictureLocation)
initRecyclerView()
with (binding){
if (indexOfFragment == 0) {
btnPrevious.isEnabled = false
btnPrevious.alpha = 0.5f
} else {
btnPrevious.isEnabled = true
btnPrevious.alpha = 1.0f
}
// If the image EXIF data contains the location, show the map icon with a green tick
if (inAppPictureLocation != null || (uploadableFile != null && uploadableFile!!.hasLocation())) {
val mapTick =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_available_20dp)
locationImageView.setImageDrawable(mapTick)
locationTextView.setText(R.string.edit_location)
} else {
// Otherwise, show the map icon with a red question mark
val mapQuestionMark = ContextCompat.getDrawable(
requireContext(),
R.drawable.ic_map_not_available_20dp
)
locationImageView.setImageDrawable(mapQuestionMark)
locationTextView.setText(R.string.add_location)
}
//If this is the last media, we have nothing to copy, lets not show the button
btnCopySubsequentMedia.visibility =
if (indexOfFragment == fragmentCallback!!.totalNumberOfSteps - 4) {
View.GONE
} else {
View.VISIBLE
}
btnNext.setOnClickListener { presenter.displayLocDialog(indexOfFragment, inAppPictureLocation, hasUserRemovedLocation) }
btnPrevious.setOnClickListener { fragmentCallback?.onPreviousButtonClicked(indexOfFragment) }
llEditImage.setOnClickListener { presenter.onEditButtonClicked(indexOfFragment) }
llContainerTitle.setOnClickListener { expandCollapseLlMediaDetail(!isExpanded) }
llLocationStatus.setOnClickListener { presenter.onMapIconClicked(indexOfFragment) }
btnCopySubsequentMedia.setOnClickListener { onButtonCopyTitleDescToSubsequentMedia() }
}
attachImageViewScaleChangeListener()
}
/**
* Attaches the scale change listener to the image view
*/
private fun attachImageViewScaleChangeListener() {
binding.backgroundImage.setOnScaleChangeListener { _: Float, _: Float, _: Float ->
//Whenever the uses plays with the image, lets collapse the media detail container
//only if it is not already collapsed, which resolves flickering of arrow
if (isExpanded) {
expandCollapseLlMediaDetail(false)
}
}
}
/**
* init the description recycler veiw and caption recyclerview
*/
private fun initRecyclerView() {
uploadMediaDetailAdapter = UploadMediaDetailAdapter(
this,
defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "")!!,
recentLanguagesDao, voiceInputResultLauncher
)
uploadMediaDetailAdapter.callback =
UploadMediaDetailAdapter.Callback { titleStringID: Int, messageStringId: Int ->
showInfoAlert(titleStringID, messageStringId)
}
uploadMediaDetailAdapter.eventListener = this
binding.rvDescriptions.layoutManager = LinearLayoutManager(context)
binding.rvDescriptions.adapter = uploadMediaDetailAdapter
}
private fun showInfoAlert(titleStringID: Int, messageStringId: Int) {
showAlertDialog(
requireActivity(),
getString(titleStringID),
getString(messageStringId),
getString(android.R.string.ok),
null
)
}
override fun showSimilarImageFragment(
originalFilePath: String?, possibleFilePath: String?,
similarImageCoordinates: ImageCoordinates?
) {
val basicKvStore = BasicKvStore(requireActivity(), "IsAnyImageCancelled")
if (!basicKvStore.getBoolean("IsAnyImageCancelled", false)) {
val newFragment = SimilarImageDialogFragment()
newFragment.isCancelable = false
newFragment.callback = object : SimilarImageDialogFragment.Callback {
override fun onPositiveResponse() {
Timber.d("positive response from similar image fragment")
presenter.useSimilarPictureCoordinates(
similarImageCoordinates!!,
indexOfFragment
)
// set the description text when user selects to use coordinate from the other image
// which was taken within 120s
// fixing: https://github.com/commons-app/apps-android-commons/issues/4700
uploadMediaDetailAdapter.items[0].descriptionText =
getString(R.string.similar_coordinate_description_auto_set)
updateMediaDetails(uploadMediaDetailAdapter.items)
// Replace the 'Add location' button with 'Edit location' button when user clicks
// yes in similar image dialog
// fixing: https://github.com/commons-app/apps-android-commons/issues/5669
val mapTick = ContextCompat.getDrawable(
requireContext(),
R.drawable.ic_map_available_20dp
)
binding.locationImageView.setImageDrawable(mapTick)
binding.locationTextView.setText(R.string.edit_location)
}
override fun onNegativeResponse() {
Timber.d("negative response from similar image fragment")
}
}
newFragment.arguments = bundleOf(
"originalImagePath" to originalFilePath,
"possibleImagePath" to possibleFilePath
)
newFragment.show(childFragmentManager, "dialog")
}
}
override fun onImageProcessed(uploadItem: UploadItem) {
if (_binding == null) {
return
}
binding.backgroundImage.setImageURI(uploadItem.mediaUri)
}
override fun onNearbyPlaceFound(
uploadItem: UploadItem, place: Place?
) {
nearbyPlace = place
this.uploadItem = uploadItem
showNearbyFound = true
if (fragmentCallback == null) {
return
}
if (indexOfFragment == 0) {
if (UploadActivity.nearbyPopupAnswers!!.containsKey(nearbyPlace!!)) {
val response = UploadActivity.nearbyPopupAnswers!![nearbyPlace!!]!!
if (response) {
if (fragmentCallback != null) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment)
}
}
} else {
showNearbyPlaceFound(nearbyPlace!!)
}
showNearbyFound = false
}
}
@SuppressLint("StringFormatInvalid") // To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format
private fun showNearbyPlaceFound(place: Place) {
val customLayout = layoutInflater.inflate(R.layout.custom_nearby_found, null)
val nearbyFoundImage = customLayout.findViewById<ImageView>(R.id.nearbyItemImage)
nearbyFoundImage.setImageURI(uploadItem!!.mediaUri)
val activity: Activity? = activity
if (activity is UploadActivity) {
val isMultipleFilesSelected = activity.isMultipleFilesSelected
// Determine the message based on the selection status
val message = if (isMultipleFilesSelected) {
// Use plural message if multiple files are selected
String.format(
Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_plural),
place.getName()
)
} else {
// Use singular message if only one file is selected
String.format(
Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_singular),
place.getName()
)
}
// Show the AlertDialog with the determined message
showAlertDialog(
requireActivity(),
getString(R.string.upload_nearby_place_found_title),
message,
{
// Execute when user confirms the upload is of the specified place
UploadActivity.nearbyPopupAnswers!![place] = true
presenter.onUserConfirmedUploadIsOfPlace(place, indexOfFragment)
},
{
// Execute when user cancels the upload of the specified place
UploadActivity.nearbyPopupAnswers!![place] = false
},
customLayout
)
}
}
override fun showProgress(shouldShow: Boolean) {
if (fragmentCallback == null) {
return
}
fragmentCallback!!.showProgress(shouldShow)
}
override fun onImageValidationSuccess() {
if (fragmentCallback == null) {
return
}
fragmentCallback!!.onNextButtonClicked(indexOfFragment)
}
/**
* This method gets called whenever the next/previous button is pressed
*/
override fun onBecameVisible() {
super.onBecameVisible()
if (fragmentCallback == null) {
return
}
presenter.fetchTitleAndDescription(indexOfFragment)
if (showNearbyFound) {
if (UploadActivity.nearbyPopupAnswers!!.containsKey(nearbyPlace!!)) {
val response = UploadActivity.nearbyPopupAnswers!![nearbyPlace!!]!!
if (response) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment)
}
} else {
showNearbyPlaceFound(nearbyPlace!!)
}
showNearbyFound = false
}
}
override fun showMessage(stringResourceId: Int, colorResourceId: Int) =
showLongToast(requireContext(), stringResourceId)
override fun showMessage(message: String, colorResourceId: Int) =
showLongToast(requireContext(), message)
override fun showDuplicatePicturePopup(uploadItem: UploadItem) {
if (defaultKvStore.getBoolean("showDuplicatePicturePopup", true)) {
val uploadTitleFormat = getString(R.string.upload_title_duplicate)
val checkBoxView = View
.inflate(activity, R.layout.nearby_permission_dialog, null)
val checkBox = checkBoxView.findViewById<View>(R.id.never_ask_again) as CheckBox
checkBox.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
if (isChecked) {
defaultKvStore.putBoolean("showDuplicatePicturePopup", false)
}
}
showAlertDialog(
requireActivity(),
getString(R.string.duplicate_file_name),
String.format(
Locale.getDefault(),
uploadTitleFormat,
uploadItem.filename
),
getString(R.string.upload),
getString(R.string.cancel),
{
uploadItem.imageQuality = ImageUtils.IMAGE_KEEP
onImageValidationSuccess()
}, null,
checkBoxView
)
} else {
uploadItem.imageQuality = ImageUtils.IMAGE_KEEP
onImageValidationSuccess()
}
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Does nothing if there is network connectivity and then the user presses okay
*/
override fun showConnectionErrorPopupForCaptionCheck() {
showAlertDialog(requireActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
{
if (!isInternetConnectionEstablished(requireActivity())) {
showConnectionErrorPopupForCaptionCheck()
}
},
{
requireActivity().finish()
})
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Recalls UploadMediaPresenter.getImageQuality for all the next upload items,
* if there is network connectivity and then the user presses okay
*/
override fun showConnectionErrorPopup() {
try {
val FLAG_ALERT_DIALOG_SHOWING = basicKvStore!!.getBoolean(
keyForShowingAlertDialog, false
)
if (!FLAG_ALERT_DIALOG_SHOWING) {
basicKvStore!!.putBoolean(keyForShowingAlertDialog, true)
showAlertDialog(
requireActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
{
basicKvStore!!.putBoolean(keyForShowingAlertDialog, false)
if (isInternetConnectionEstablished(requireActivity())) {
val sizeOfUploads = basicKvStore!!.getInt(
UploadActivity.keyForCurrentUploadImagesSize
)
for (i in indexOfFragment until sizeOfUploads) {
presenter.getImageQuality(
i,
inAppPictureLocation,
requireActivity()
)
}
} else {
showConnectionErrorPopup()
}
},
{
basicKvStore!!.putBoolean(keyForShowingAlertDialog, false)
requireActivity().finish()
},
null
)
}
} catch (e: Exception) {
Timber.e(e)
}
}
override fun showExternalMap(uploadItem: UploadItem) =
goToLocationPickerActivity(uploadItem)
/**
* Launches the image editing activity to edit the specified UploadItem.
*
* @param uploadItem The UploadItem to be edited.
*
* This method is called to start the image editing activity for a specific UploadItem.
* It sets the UploadItem as the currently editable item, creates an intent to launch the
* EditActivity, and passes the image file path as an extra in the intent. The activity
* is started using resultLauncher that handles the result in respective callback.
*/
override fun showEditActivity(uploadItem: UploadItem) {
editableUploadItem = uploadItem
val intent = Intent(context, EditActivity::class.java)
intent.putExtra("image", uploadableFile!!.getFilePath().toString())
startForEditActivityResult.launch(intent)
}
/**
* Start Location picker activity. Show the location first then user can modify it by clicking
* modify location button.
* @param uploadItem current upload item
*/
private fun goToLocationPickerActivity(uploadItem: UploadItem) {
editableUploadItem = uploadItem
var defaultLatitude = 37.773972
var defaultLongitude = -122.431297
var defaultZoom = 16.0
val locationPickerIntent: Intent
/* Retrieve image location from EXIF if present or
check if user has provided location while using the in-app camera.
Use location of last UploadItem if none of them is available */
if (uploadItem.gpsCoords != null && uploadItem.gpsCoords!!
.decLatitude != 0.0 && uploadItem.gpsCoords!!.decLongitude != 0.0
) {
defaultLatitude = uploadItem.gpsCoords!!
.decLatitude
defaultLongitude = uploadItem.gpsCoords!!.decLongitude
defaultZoom = uploadItem.gpsCoords!!.zoomLevel
locationPickerIntent = LocationPicker.IntentBuilder()
.defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, defaultZoom))
.activityKey("UploadActivity")
.build(requireActivity())
} else {
if (defaultKvStore.getString(LAST_LOCATION) != null) {
val locationLatLng = defaultKvStore.getString(LAST_LOCATION)!!
.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
defaultLatitude = locationLatLng[0].toDouble()
defaultLongitude = locationLatLng[1].toDouble()
}
if (defaultKvStore.getString(LAST_ZOOM) != null) {
defaultZoom = defaultKvStore.getString(LAST_ZOOM)!!
.toDouble()
}
locationPickerIntent = LocationPicker.IntentBuilder()
.defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, defaultZoom))
.activityKey("NoLocationUploadActivity")
.build(requireActivity())
}
startForResult.launch(locationPickerIntent)
}
private fun onCameraPosition(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK) {
checkNotNull(result.data)
val cameraPosition = getCameraPosition(
result.data!!
)
if (cameraPosition != null) {
val latitude = cameraPosition.latitude.toString()
val longitude = cameraPosition.longitude.toString()
val zoom = cameraPosition.zoom
editLocation(latitude, longitude, zoom)
// If isMissingLocationDialog is true, it means that the user has already tapped the
// "Next" button, so go directly to the next step.
if (isMissingLocationDialog) {
isMissingLocationDialog = false
presenter.displayLocDialog(
indexOfFragment,
inAppPictureLocation,
hasUserRemovedLocation
)
}
} else {
// If camera position is null means location is removed by the user
removeLocation()
}
}
}
private fun onVoiceInput(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val resultData = result.data!!.getStringArrayListExtra(
RecognizerIntent.EXTRA_RESULTS
)
uploadMediaDetailAdapter.handleSpeechResult(resultData!![0])
} else {
Timber.e("Error %s", result.resultCode)
}
}
private fun onEditActivityResult(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK) {
val path = result.data!!.getStringExtra("editedImageFilePath")
if (Objects.equals(result, "Error")) {
Timber.e("Error in rotating image")
return
}
try {
if (_binding != null) {
binding.backgroundImage.setImageURI(Uri.fromFile(File(path!!)))
}
editableUploadItem!!.setContentAndMediaUri(Uri.fromFile(File(path!!)))
fragmentCallback!!.changeThumbnail(
indexOfFragment,
path
)
} catch (e: Exception) {
Timber.e(e)
}
}
}
/**
* Removes the location data from the image, by setting them to null
*/
private fun removeLocation() {
editableUploadItem!!.gpsCoords!!.decimalCoords = null
try {
val sourceExif = ExifInterface(
uploadableFile!!.getFilePath()
)
val exifTags = arrayOf(
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
)
for (tag in exifTags) {
sourceExif.setAttribute(tag, null)
}
sourceExif.saveAttributes()
val mapQuestion =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_not_available_20dp)
if (_binding != null) {
binding.locationImageView.setImageDrawable(mapQuestion)
binding.locationTextView.setText(R.string.add_location)
}
editableUploadItem!!.gpsCoords!!.decLatitude = 0.0
editableUploadItem!!.gpsCoords!!.decLongitude = 0.0
editableUploadItem!!.gpsCoords!!.imageCoordsExists = false
hasUserRemovedLocation = true
Toast.makeText(context, getString(R.string.location_removed), Toast.LENGTH_LONG)
.show()
} catch (e: Exception) {
Timber.d(e)
Toast.makeText(
context, "Location could not be removed due to internal error",
Toast.LENGTH_LONG
).show()
}
}
/**
* Update the old coordinates with new one
* @param latitude new latitude
* @param longitude new longitude
*/
fun editLocation(latitude: String, longitude: String, zoom: Double) {
editableUploadItem!!.gpsCoords!!.decLatitude = latitude.toDouble()
editableUploadItem!!.gpsCoords!!.decLongitude = longitude.toDouble()
editableUploadItem!!.gpsCoords!!.decimalCoords = "$latitude|$longitude"
editableUploadItem!!.gpsCoords!!.imageCoordsExists = true
editableUploadItem!!.gpsCoords!!.zoomLevel = zoom
// Replace the map icon using the one with a green tick
val mapTick = ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_available_20dp)
if (_binding != null) {
binding.locationImageView.setImageDrawable(mapTick)
binding.locationTextView.setText(R.string.edit_location)
}
Toast.makeText(context, getString(R.string.location_updated), Toast.LENGTH_LONG).show()
}
override fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail>) {
uploadMediaDetailAdapter.items = uploadMediaDetails
showNearbyFound =
showNearbyFound && (uploadMediaDetails.isEmpty() || listContainsEmptyDetails(
uploadMediaDetails
))
}
/**
* if the media details that come in here are empty
* (empty caption AND empty description, with caption being the decider here)
* this method allows usage of nearby place caption and description if any
* else it takes the media details saved in prior for this picture
* @param uploadMediaDetails saved media details,
* ex: in case when "copy to subsequent media" button is clicked
* for a previous image
* @return boolean whether the details are empty or not
*/
private fun listContainsEmptyDetails(uploadMediaDetails: List<UploadMediaDetail>): Boolean {
for ((_, descriptionText, captionText) in uploadMediaDetails) {
if (!TextUtils.isEmpty(captionText) && !TextUtils.isEmpty(
descriptionText
)
) {
return false
}
}
return true
}
/**
* Showing dialog for adding location
*
* @param runnable proceed for verifying image quality
*/
override fun displayAddLocationDialog(runnable: Runnable) {
isMissingLocationDialog = true
showAlertDialog(
requireActivity(),
getString(R.string.no_location_found_title),
getString(R.string.no_location_found_message),
getString(R.string.add_location),
getString(R.string.skip_login),
{
presenter.onMapIconClicked(indexOfFragment)
},
runnable
)
}
override fun showBadImagePopup(errorCode: Int, index: Int, uploadItem: UploadItem) {
//If the error message is null, we will probably not show anything
val activity = requireActivity()
val errorMessageForResult = getErrorMessageForResult(activity, errorCode)
if (errorMessageForResult.isNotEmpty()) {
showAlertDialog(
activity,
activity.getString(R.string.upload_problem_image),
errorMessageForResult,
activity.getString(R.string.upload),
activity.getString(R.string.cancel),
{
showProgress(false)
uploadItem.imageQuality = IMAGE_OK
},
{
presenterCallback!!.deletePictureAtIndex(index)
}
)?.setCancelable(false)
}
}
override fun onDestroyView() {
super.onDestroyView()
presenter.onDetachView()
}
fun expandCollapseLlMediaDetail(shouldExpand: Boolean) {
if (_binding == null) {
return
}
binding.llContainerMediaDetail.visibility =
if (shouldExpand) View.VISIBLE else View.GONE
isExpanded = !isExpanded
binding.ibExpandCollapse.rotation = binding.ibExpandCollapse.rotation + 180
}
override fun onPrimaryCaptionTextChange(isNotEmpty: Boolean) {
if (_binding == null) {
return
}
binding.btnCopySubsequentMedia.isEnabled = isNotEmpty
binding.btnCopySubsequentMedia.isClickable = isNotEmpty
binding.btnCopySubsequentMedia.alpha = if (isNotEmpty) 1.0f else 0.5f
binding.btnNext.isEnabled = isNotEmpty
binding.btnNext.isClickable = isNotEmpty
binding.btnNext.alpha = if (isNotEmpty) 1.0f else 0.5f
}
/**
* Adds new language item to RecyclerView
*/
override fun addLanguage() {
val uploadMediaDetail = UploadMediaDetail()
uploadMediaDetail.isManuallyAdded = true //This was manually added by the user
uploadMediaDetailAdapter.addDescription(uploadMediaDetail)
binding.rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1)
}
fun onButtonCopyTitleDescToSubsequentMedia() {
presenter.copyTitleAndDescriptionToSubsequentMedia(indexOfFragment)
Toast.makeText(context, R.string.copied_successfully, Toast.LENGTH_SHORT).show()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (uploadableFile != null) {
outState.putParcelable(UPLOADABLE_FILE, uploadableFile)
}
outState.putParcelableArrayList(
UPLOAD_MEDIA_DETAILS,
ArrayList(uploadMediaDetailAdapter.items)
)
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
interface UploadMediaDetailFragmentCallback : Callback {
fun deletePictureAtIndex(index: Int)
fun changeThumbnail(index: Int, uri: String)
}
companion object {
/**
* A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex.
* 12.3433,54.78897 from applicationKvStore.
*/
const val LAST_LOCATION: String = "last_location_while_uploading"
const val LAST_ZOOM: String = "last_zoom_level_while_uploading"
const val UPLOADABLE_FILE: String = "uploadable_file"
const val UPLOAD_MEDIA_DETAILS: String = "upload_media_detail_adapter"
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.upload.mediaDetails
import android.app.Activity
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.upload.ImageCoordinates
@ -15,9 +16,9 @@ import fr.free.nrw.commons.upload.UploadMediaDetail
*/
interface UploadMediaDetailsContract {
interface View : SimilarImageInterface {
fun onImageProcessed(uploadItem: UploadItem?, place: Place?)
fun onImageProcessed(uploadItem: UploadItem)
fun onNearbyPlaceFound(uploadItem: UploadItem?, place: Place?)
fun onNearbyPlaceFound(uploadItem: UploadItem, place: Place?)
fun showProgress(shouldShow: Boolean)
@ -25,9 +26,9 @@ interface UploadMediaDetailsContract {
fun showMessage(stringResourceId: Int, colorResourceId: Int)
fun showMessage(message: String?, colorResourceId: Int)
fun showMessage(message: String, colorResourceId: Int)
fun showDuplicatePicturePopup(uploadItem: UploadItem?)
fun showDuplicatePicturePopup(uploadItem: UploadItem)
/**
* Shows a dialog alerting the user that internet connection is required for upload process
@ -42,16 +43,20 @@ interface UploadMediaDetailsContract {
*/
fun showConnectionErrorPopupForCaptionCheck()
fun showExternalMap(uploadItem: UploadItem?)
fun showExternalMap(uploadItem: UploadItem)
fun showEditActivity(uploadItem: UploadItem?)
fun showEditActivity(uploadItem: UploadItem)
fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail?>?)
fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail>)
fun displayAddLocationDialog(runnable: Runnable?)
fun displayAddLocationDialog(runnable: Runnable)
fun showBadImagePopup(errorCode: Int, index: Int, uploadItem: UploadItem)
}
interface UserActionListener : BasePresenter<View?> {
fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore)
fun receiveImage(
uploadableFile: UploadableFile?,
place: Place?,
@ -59,7 +64,7 @@ interface UploadMediaDetailsContract {
)
fun setUploadMediaDetails(
uploadMediaDetails: List<UploadMediaDetail?>?,
uploadMediaDetails: List<UploadMediaDetail>,
uploadItemIndex: Int
)
@ -74,7 +79,7 @@ interface UploadMediaDetailsContract {
fun getImageQuality(
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
activity: Activity?
activity: Activity
): Boolean
/**
@ -87,7 +92,8 @@ interface UploadMediaDetailsContract {
* @param hasUserRemovedLocation True if user has removed location from the image
*/
fun displayLocDialog(
uploadItemIndex: Int, inAppPictureLocation: LatLng?,
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
hasUserRemovedLocation: Boolean
)
@ -97,7 +103,7 @@ interface UploadMediaDetailsContract {
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
fun checkImageQuality(uploadItem: UploadItem?, index: Int)
fun checkImageQuality(uploadItem: UploadItem, index: Int)
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
@ -111,7 +117,7 @@ interface UploadMediaDetailsContract {
fun fetchTitleAndDescription(indexInViewFlipper: Int)
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates?, uploadItemIndex: Int)
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int)
fun onMapIconClicked(indexInViewFlipper: Int)

View file

@ -1,547 +0,0 @@
package fr.free.nrw.commons.upload.mediaDetails;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.activity;
import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION;
import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult;
import android.app.Activity;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.ImageCoordinates;
import fr.free.nrw.commons.upload.SimilarImageInterface;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadItem;
import fr.free.nrw.commons.upload.UploadMediaDetail;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.UserActionListener;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.View;
import fr.free.nrw.commons.utils.DialogUtil;
import io.github.coordinates2country.Coordinates2Country;
import io.reactivex.Maybe;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import java.lang.reflect.Proxy;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.json.JSONObject;
import timber.log.Timber;
public class UploadMediaPresenter implements UserActionListener, SimilarImageInterface {
private static final UploadMediaDetailsContract.View DUMMY = (UploadMediaDetailsContract.View) Proxy
.newProxyInstance(
UploadMediaDetailsContract.View.class.getClassLoader(),
new Class[]{UploadMediaDetailsContract.View.class},
(proxy, method, methodArgs) -> null);
private final UploadRepository repository;
private UploadMediaDetailsContract.View view = DUMMY;
private CompositeDisposable compositeDisposable;
private final JsonKvStore defaultKVStore;
private Scheduler ioScheduler;
private Scheduler mainThreadScheduler;
public static UploadMediaDetailFragmentCallback presenterCallback ;
private final List<String> WLM_SUPPORTED_COUNTRIES= Arrays.asList("am","at","az","br","hr","sv","fi","fr","de","gh","in","ie","il","mk","my","mt","pk","pe","pl","ru","rw","si","es","se","tw","ug","ua","us");
private Map<String, String> countryNamesAndCodes = null;
private final String keyForCurrentUploadImageQualities = "UploadedImagesQualities";
/**
* Variable used to determine if the battery-optimisation dialog is being shown or not
*/
public static boolean isBatteryDialogShowing;
public static boolean isCategoriesDialogShowing;
@Inject
public UploadMediaPresenter(final UploadRepository uploadRepository,
@Named("default_preferences") final JsonKvStore defaultKVStore,
@Named(IO_THREAD) final Scheduler ioScheduler,
@Named(MAIN_THREAD) final Scheduler mainThreadScheduler) {
this.repository = uploadRepository;
this.defaultKVStore = defaultKVStore;
this.ioScheduler = ioScheduler;
this.mainThreadScheduler = mainThreadScheduler;
compositeDisposable = new CompositeDisposable();
}
@Override
public void onAttachView(final View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
compositeDisposable.clear();
}
/**
* Sets the Upload Media Details for the corresponding upload item
*/
@Override
public void setUploadMediaDetails(final List<UploadMediaDetail> uploadMediaDetails, final int uploadItemIndex) {
repository.getUploads().get(uploadItemIndex).setUploadMediaDetails(uploadMediaDetails);
}
/**
* Receives the corresponding uploadable file, processes it and return the view with and uplaod item
*/
@Override
public void receiveImage(final UploadableFile uploadableFile, final Place place,
final LatLng inAppPictureLocation) {
view.showProgress(true);
compositeDisposable.add(
repository
.preProcessImage(uploadableFile, place, this, inAppPictureLocation)
.map(uploadItem -> {
if(place!=null && place.isMonument()){
if (place.location != null) {
final String countryCode = reverseGeoCode(place.location);
if (countryCode != null && WLM_SUPPORTED_COUNTRIES
.contains(countryCode.toLowerCase(Locale.ROOT))) {
uploadItem.setWLMUpload(true);
uploadItem.setCountryCode(countryCode.toLowerCase(Locale.ROOT));
}
}
}
return uploadItem;
})
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(uploadItem ->
{
view.onImageProcessed(uploadItem, place);
view.updateMediaDetails(uploadItem.getUploadMediaDetails());
view.showProgress(false);
final ImageCoordinates gpsCoords = uploadItem.getGpsCoords();
final boolean hasImageCoordinates =
gpsCoords != null && gpsCoords.getImageCoordsExists();
if (hasImageCoordinates && place == null) {
checkNearbyPlaces(uploadItem);
}
},
throwable -> Timber.e(throwable, "Error occurred in processing images")));
}
@Nullable
private String reverseGeoCode(final LatLng latLng){
if(countryNamesAndCodes == null){
countryNamesAndCodes = getCountryNamesAndCodes();
}
return countryNamesAndCodes.get(Coordinates2Country.country(latLng.getLatitude(), latLng.getLongitude()));
}
/**
* Creates HashMap containing all ISO countries 2-letter codes provided by <code>Locale.getISOCountries()</code>
* and their english names
*
* @return HashMap where Key is country english name and Value is 2-letter country code
* e.g. ["Germany":"DE", ...]
*/
private Map<String, String> getCountryNamesAndCodes(){
final Map<String, String> result = new HashMap<>();
final String[] isoCountries = Locale.getISOCountries();
for (final String isoCountry : isoCountries) {
result.put(
new Locale("en", isoCountry).getDisplayCountry(Locale.ENGLISH),
isoCountry
);
}
return result;
}
/**
* This method checks for the nearest location that needs images and suggests it to the user.
*/
private void checkNearbyPlaces(final UploadItem uploadItem) {
final Disposable checkNearbyPlaces = Maybe.fromCallable(() -> repository
.checkNearbyPlaces(uploadItem.getGpsCoords().getDecLatitude(),
uploadItem.getGpsCoords().getDecLongitude()))
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(place -> {
if (place != null) {
view.onNearbyPlaceFound(uploadItem, place);
}
},
throwable -> Timber.e(throwable, "Error occurred in processing images"));
compositeDisposable.add(checkNearbyPlaces);
}
/**
* Checks if the image has a location. Displays a dialog alerting user that no
* location has been to added to the image and asking them to add one, if location was not
* removed by the user
*
* @param uploadItemIndex Index of the uploadItem which has no location
* @param inAppPictureLocation In app picture location (if any)
* @param hasUserRemovedLocation True if user has removed location from the image
*/
@Override
public void displayLocDialog(final int uploadItemIndex, final LatLng inAppPictureLocation,
final boolean hasUserRemovedLocation) {
final List<UploadItem> uploadItems = repository.getUploads();
final UploadItem uploadItem = uploadItems.get(uploadItemIndex);
if (uploadItem.getGpsCoords().getDecimalCoords() == null && inAppPictureLocation == null
&& !hasUserRemovedLocation) {
final Runnable onSkipClicked = () -> {
verifyCaptionQuality(uploadItem);
};
view.displayAddLocationDialog(onSkipClicked);
} else {
verifyCaptionQuality(uploadItem);
}
}
/**
* Verifies the image's caption and calls function to handle the result
*
* @param uploadItem UploadItem whose caption is checked
*/
private void verifyCaptionQuality(final UploadItem uploadItem) {
view.showProgress(true);
compositeDisposable.add(
repository
.getCaptionQuality(uploadItem)
.observeOn(mainThreadScheduler)
.subscribe(capResult -> {
view.showProgress(false);
handleCaptionResult(capResult, uploadItem);
},
throwable -> {
view.showProgress(false);
if (throwable instanceof UnknownHostException) {
view.showConnectionErrorPopupForCaptionCheck();
} else {
view.showMessage("" + throwable.getLocalizedMessage(),
R.color.color_error);
}
Timber.e(throwable, "Error occurred while handling image");
})
);
}
/**
* Handles image's caption results and shows dialog if necessary
*
* @param errorCode Error code of the UploadItem
* @param uploadItem UploadItem whose caption is checked
*/
public void handleCaptionResult(final Integer errorCode, final UploadItem uploadItem) {
// If errorCode is empty caption show message
if (errorCode == EMPTY_CAPTION) {
Timber.d("Captions are empty. Showing toast");
view.showMessage(R.string.add_caption_toast, R.color.color_error);
}
// If image with same file name exists check the bit in errorCode is set or not
if ((errorCode & FILE_NAME_EXISTS) != 0) {
Timber.d("Trying to show duplicate picture popup");
view.showDuplicatePicturePopup(uploadItem);
}
// If caption is not duplicate or user still wants to upload it
if (errorCode == IMAGE_OK) {
Timber.d("Image captions are okay or user still wants to upload it");
view.onImageValidationSuccess();
}
}
/**
* Copies the caption and description of the current item to the subsequent media
*/
@Override
public void copyTitleAndDescriptionToSubsequentMedia(final int indexInViewFlipper) {
for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){
final UploadItem subsequentUploadItem = repository.getUploads().get(i);
subsequentUploadItem.setUploadMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails()));
}
}
/**
* Fetches and set the caption and description of the item
*/
@Override
public void fetchTitleAndDescription(final int indexInViewFlipper) {
final UploadItem currentUploadItem = repository.getUploads().get(indexInViewFlipper);
view.updateMediaDetails(currentUploadItem.getUploadMediaDetails());
}
@NotNull
private List<UploadMediaDetail> deepCopy(final List<UploadMediaDetail> uploadMediaDetails) {
final ArrayList<UploadMediaDetail> newList = new ArrayList<>();
for (final UploadMediaDetail uploadMediaDetail : uploadMediaDetails) {
newList.add(uploadMediaDetail.javaCopy());
}
return newList;
}
@Override
public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) {
repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex);
}
@Override
public void onMapIconClicked(final int indexInViewFlipper) {
view.showExternalMap(repository.getUploads().get(indexInViewFlipper));
}
@Override
public void onEditButtonClicked(final int indexInViewFlipper){
view.showEditActivity(repository.getUploads().get(indexInViewFlipper));
}
/**
* Updates the information regarding the specified place for the specified upload item
* when the user confirms the suggested nearby place.
*
* @param place The place to be associated with the uploads.
* @param uploadItemIndex Index of the uploadItem whose detected place has been confirmed
*/
@Override
public void onUserConfirmedUploadIsOfPlace(final Place place, final int uploadItemIndex) {
final UploadItem uploadItem = repository.getUploads().get(uploadItemIndex);
uploadItem.setPlace(place);
final List<UploadMediaDetail> uploadMediaDetails = uploadItem.getUploadMediaDetails();
// Update UploadMediaDetail object for this UploadItem
uploadMediaDetails.set(0, new UploadMediaDetail(place));
// Now that the UploadItem and its associated UploadMediaDetail objects have been updated,
// update the view with the modified media details of the first upload item
view.updateMediaDetails(uploadMediaDetails);
UploadActivity.setUploadIsOfAPlace(true);
}
/**
* Calculates the image quality
*
* @param uploadItemIndex Index of the UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @param activity Context reference
* @return true if no internal error occurs, else returns false
*/
@Override
public boolean getImageQuality(final int uploadItemIndex, final LatLng inAppPictureLocation,
final Activity activity) {
final List<UploadItem> uploadItems = repository.getUploads();
view.showProgress(true);
if (uploadItems.isEmpty()) {
view.showProgress(false);
// No internationalization required for this error message because it's an internal error.
view.showMessage(
"Internal error: Zero upload items received by the Upload Media Detail Fragment. Sorry, please upload again.",
R.color.color_error);
return false;
}
final UploadItem uploadItem = uploadItems.get(uploadItemIndex);
compositeDisposable.add(
repository
.getImageQuality(uploadItem, inAppPictureLocation)
.observeOn(mainThreadScheduler)
.subscribe(imageResult -> {
storeImageQuality(imageResult, uploadItemIndex, activity, uploadItem);
},
throwable -> {
if (throwable instanceof UnknownHostException) {
view.showProgress(false);
view.showConnectionErrorPopup();
} else {
view.showMessage("" + throwable.getLocalizedMessage(),
R.color.color_error);
}
Timber.e(throwable, "Error occurred while handling image");
})
);
return true;
}
/**
* Stores the image quality in JSON format in SharedPrefs
*
* @param imageResult Image quality
* @param uploadItemIndex Index of the UploadItem whose quality is calculated
* @param activity Context reference
* @param uploadItem UploadItem whose quality is to be checked
*/
private void storeImageQuality(final Integer imageResult, final int uploadItemIndex, final Activity activity,
final UploadItem uploadItem) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
jsonObject.put("UploadItem" + uploadItemIndex, imageResult);
store.putString(keyForCurrentUploadImageQualities, jsonObject.toString());
} catch (final Exception e) {
Timber.e(e);
}
if (uploadItemIndex == 0) {
if (!isBatteryDialogShowing && !isCategoriesDialogShowing) {
// if battery-optimisation dialog is not being shown, call checkImageQuality
checkImageQuality(uploadItem, uploadItemIndex);
} else {
view.showProgress(false);
}
}
}
/**
* Used to check image quality from stored qualities and display dialogs
*
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
@Override
public void checkImageQuality(final UploadItem uploadItem, final int index) {
if ((uploadItem.getImageQuality() != IMAGE_OK) && (uploadItem.getImageQuality()
!= IMAGE_KEEP)) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
final Integer imageQuality = (int) jsonObject.get("UploadItem" + index);
view.showProgress(false);
if (imageQuality == IMAGE_OK) {
uploadItem.setHasInvalidLocation(false);
uploadItem.setImageQuality(imageQuality);
} else {
handleBadImage(imageQuality, uploadItem, index);
}
} catch (final Exception e) {
}
}
}
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
*
* @param size Size of uploadableFiles
* @param index Index of the UploadItem which was deleted
*/
@Override
public void updateImageQualitiesJSON(final int size, final int index) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
for (int i = index; i < (size - 1); i++) {
jsonObject.put("UploadItem" + i, jsonObject.get("UploadItem" + (i + 1)));
}
jsonObject.remove("UploadItem" + (size - 1));
store.putString(keyForCurrentUploadImageQualities, jsonObject.toString());
} catch (final Exception e) {
Timber.e(e);
}
}
/**
* Handles bad pictures, like too dark, already on wikimedia, downloaded from internet
*
* @param errorCode Error code of the bad image quality
* @param uploadItem UploadItem whose quality is bad
* @param index Index of item whose quality is bad
*/
public void handleBadImage(final Integer errorCode,
final UploadItem uploadItem, final int index) {
Timber.d("Handle bad picture with error code %d", errorCode);
if (errorCode >= 8) { // If location of image and nearby does not match
uploadItem.setHasInvalidLocation(true);
}
// If image has some other problems, show popup accordingly
if (errorCode != EMPTY_CAPTION && errorCode != FILE_NAME_EXISTS) {
showBadImagePopup(errorCode, index, activity, uploadItem);
}
}
/**
* Shows a dialog describing the potential problems in the current image
*
* @param errorCode Has the potential problems in the current image
* @param index Index of the UploadItem which has problems
* @param activity Context reference
* @param uploadItem UploadItem which has problems
*/
public void showBadImagePopup(final Integer errorCode,
final int index, final Activity activity, final UploadItem uploadItem) {
final String errorMessageForResult = getErrorMessageForResult(activity, errorCode);
if (!StringUtils.isBlank(errorMessageForResult)) {
DialogUtil.showAlertDialog(activity,
activity.getString(R.string.upload_problem_image),
errorMessageForResult,
activity.getString(R.string.upload),
activity.getString(R.string.cancel),
() -> {
view.showProgress(false);
uploadItem.setImageQuality(IMAGE_OK);
},
() -> {
presenterCallback.deletePictureAtIndex(index);
}
).setCancelable(false);
}
//If the error message is null, we will probably not show anything
}
/**
* notifies the user that a similar image exists
*/
@Override
public void showSimilarImageFragment(final String originalFilePath, final String possibleFilePath,
final ImageCoordinates similarImageCoordinates) {
view.showSimilarImageFragment(originalFilePath, possibleFilePath,
similarImageCoordinates
);
}
}

View file

@ -0,0 +1,441 @@
package fr.free.nrw.commons.upload.mediaDetails
import android.app.Activity
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.SimilarImageInterface
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadActivity.Companion.setUploadIsOfAPlace
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadMediaDetail
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback
import fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION
import fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK
import io.github.coordinates2country.Coordinates2Country
import io.reactivex.Maybe
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import org.json.JSONObject
import timber.log.Timber
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import java.net.UnknownHostException
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
class UploadMediaPresenter @Inject constructor(
private val repository: UploadRepository,
@param:Named(IO_THREAD) private val ioScheduler: Scheduler,
@param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler
) : UploadMediaDetailsContract.UserActionListener, SimilarImageInterface {
private var view = DUMMY
private val compositeDisposable = CompositeDisposable()
private val countryNamesAndCodes: Map<String, String> by lazy {
// Create a map containing all ISO countries 2-letter codes provided by
// `Locale.getISOCountries()` and their english names
buildMap {
Locale.getISOCountries().forEach {
put(Locale("en", it).getDisplayCountry(Locale.ENGLISH), it)
}
}
}
lateinit var basicKvStoreFactory: (String) -> BasicKvStore
override fun onAttachView(view: UploadMediaDetailsContract.View) {
this.view = view
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
/**
* Sets the Upload Media Details for the corresponding upload item
*/
override fun setUploadMediaDetails(
uploadMediaDetails: List<UploadMediaDetail>,
uploadItemIndex: Int
) {
repository.getUploads()[uploadItemIndex].uploadMediaDetails = uploadMediaDetails.toMutableList()
}
override fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore) {
basicKvStoreFactory = factory
}
/**
* Receives the corresponding uploadable file, processes it and return the view with and uplaod item
*/
override fun receiveImage(
uploadableFile: UploadableFile?,
place: Place?,
inAppPictureLocation: LatLng?
) {
view.showProgress(true)
compositeDisposable.add(
repository.preProcessImage(
uploadableFile, place, this, inAppPictureLocation
).map { uploadItem: UploadItem ->
if (place != null && place.isMonument && place.location != null) {
val countryCode = countryNamesAndCodes[Coordinates2Country.country(
place.location.latitude,
place.location.longitude
)]
if (countryCode != null && WLM_SUPPORTED_COUNTRIES.contains(countryCode.lowercase())) {
uploadItem.isWLMUpload = true
uploadItem.countryCode = countryCode.lowercase()
}
}
uploadItem
}.subscribeOn(ioScheduler).observeOn(mainThreadScheduler)
.subscribe({ uploadItem: UploadItem ->
view.onImageProcessed(uploadItem)
view.updateMediaDetails(uploadItem.uploadMediaDetails)
view.showProgress(false)
val gpsCoords = uploadItem.gpsCoords
val hasImageCoordinates = gpsCoords != null && gpsCoords.imageCoordsExists
if (hasImageCoordinates && place == null) {
checkNearbyPlaces(uploadItem)
}
}, { throwable: Throwable? ->
Timber.e(throwable, "Error occurred in processing images")
})
)
}
/**
* This method checks for the nearest location that needs images and suggests it to the user.
*/
private fun checkNearbyPlaces(uploadItem: UploadItem) {
compositeDisposable.add(Maybe.fromCallable {
repository.checkNearbyPlaces(
uploadItem.gpsCoords!!.decLatitude, uploadItem.gpsCoords!!.decLongitude
)
}.subscribeOn(ioScheduler).observeOn(mainThreadScheduler).subscribe({
view.onNearbyPlaceFound(uploadItem, it)
}, { throwable: Throwable? ->
Timber.e(throwable, "Error occurred in processing images")
})
)
}
/**
* Checks if the image has a location. Displays a dialog alerting user that no
* location has been to added to the image and asking them to add one, if location was not
* removed by the user
*
* @param uploadItemIndex Index of the uploadItem which has no location
* @param inAppPictureLocation In app picture location (if any)
* @param hasUserRemovedLocation True if user has removed location from the image
*/
override fun displayLocDialog(
uploadItemIndex: Int, inAppPictureLocation: LatLng?,
hasUserRemovedLocation: Boolean
) {
val uploadItem = repository.getUploads()[uploadItemIndex]
if (uploadItem.gpsCoords!!.decimalCoords == null && inAppPictureLocation == null && !hasUserRemovedLocation) {
view.displayAddLocationDialog { verifyCaptionQuality(uploadItem) }
} else {
verifyCaptionQuality(uploadItem)
}
}
/**
* Verifies the image's caption and calls function to handle the result
*
* @param uploadItem UploadItem whose caption is checked
*/
private fun verifyCaptionQuality(uploadItem: UploadItem) {
view.showProgress(true)
compositeDisposable.add(repository.getCaptionQuality(uploadItem)
.observeOn(mainThreadScheduler)
.subscribe({ capResult: Int ->
view.showProgress(false)
handleCaptionResult(capResult, uploadItem)
}, { throwable: Throwable ->
view.showProgress(false)
if (throwable is UnknownHostException) {
view.showConnectionErrorPopupForCaptionCheck()
} else {
view.showMessage(throwable.localizedMessage, R.color.color_error)
}
Timber.e(throwable, "Error occurred while handling image")
})
)
}
/**
* Handles image's caption results and shows dialog if necessary
*
* @param errorCode Error code of the UploadItem
* @param uploadItem UploadItem whose caption is checked
*/
fun handleCaptionResult(errorCode: Int, uploadItem: UploadItem) {
// If errorCode is empty caption show message
if (errorCode == EMPTY_CAPTION) {
Timber.d("Captions are empty. Showing toast")
view.showMessage(R.string.add_caption_toast, R.color.color_error)
}
// If image with same file name exists check the bit in errorCode is set or not
if ((errorCode and FILE_NAME_EXISTS) != 0) {
Timber.d("Trying to show duplicate picture popup")
view.showDuplicatePicturePopup(uploadItem)
}
// If caption is not duplicate or user still wants to upload it
if (errorCode == IMAGE_OK) {
Timber.d("Image captions are okay or user still wants to upload it")
view.onImageValidationSuccess()
}
}
/**
* Copies the caption and description of the current item to the subsequent media
*/
override fun copyTitleAndDescriptionToSubsequentMedia(indexInViewFlipper: Int) {
for (i in indexInViewFlipper + 1 until repository.getCount()) {
val subsequentUploadItem = repository.getUploads()[i]
subsequentUploadItem.uploadMediaDetails = deepCopy(
repository.getUploads()[indexInViewFlipper].uploadMediaDetails
).toMutableList()
}
}
/**
* Fetches and set the caption and description of the item
*/
override fun fetchTitleAndDescription(indexInViewFlipper: Int) =
view.updateMediaDetails(repository.getUploads()[indexInViewFlipper].uploadMediaDetails)
private fun deepCopy(uploadMediaDetails: List<UploadMediaDetail>) =
uploadMediaDetails.map(UploadMediaDetail::javaCopy)
override fun useSimilarPictureCoordinates(
imageCoordinates: ImageCoordinates, uploadItemIndex: Int
) = repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex)
override fun onMapIconClicked(indexInViewFlipper: Int) =
view.showExternalMap(repository.getUploads()[indexInViewFlipper])
override fun onEditButtonClicked(indexInViewFlipper: Int) =
view.showEditActivity(repository.getUploads()[indexInViewFlipper])
/**
* Updates the information regarding the specified place for the specified upload item
* when the user confirms the suggested nearby place.
*
* @param place The place to be associated with the uploads.
* @param uploadItemIndex Index of the uploadItem whose detected place has been confirmed
*/
override fun onUserConfirmedUploadIsOfPlace(place: Place?, uploadItemIndex: Int) {
val uploadItem = repository.getUploads()[uploadItemIndex]
uploadItem.place = place
val uploadMediaDetails = uploadItem.uploadMediaDetails
// Update UploadMediaDetail object for this UploadItem
uploadMediaDetails[0] = UploadMediaDetail(place)
// Now that the UploadItem and its associated UploadMediaDetail objects have been updated,
// update the view with the modified media details of the first upload item
view.updateMediaDetails(uploadMediaDetails)
setUploadIsOfAPlace(true)
}
/**
* Calculates the image quality
*
* @param uploadItemIndex Index of the UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @param activity Context reference
* @return true if no internal error occurs, else returns false
*/
override fun getImageQuality(
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
activity: Activity
): Boolean {
val uploadItems = repository.getUploads()
view.showProgress(true)
if (uploadItems.isEmpty()) {
view.showProgress(false)
// No internationalization required for this error message because it's an internal error.
view.showMessage(
"Internal error: Zero upload items received by the Upload Media Detail Fragment. Sorry, please upload again.",
R.color.color_error
)
return false
}
val uploadItem = uploadItems[uploadItemIndex]
compositeDisposable.add(repository.getImageQuality(uploadItem, inAppPictureLocation)
.observeOn(mainThreadScheduler)
.subscribe({ imageResult: Int ->
storeImageQuality(imageResult, uploadItemIndex, activity, uploadItem)
}, { throwable: Throwable ->
if (throwable is UnknownHostException) {
view.showProgress(false)
view.showConnectionErrorPopup()
} else {
view.showMessage(throwable.localizedMessage, R.color.color_error)
}
Timber.e(throwable, "Error occurred while handling image")
})
)
return true
}
/**
* Stores the image quality in JSON format in SharedPrefs
*
* @param imageResult Image quality
* @param uploadItemIndex Index of the UploadItem whose quality is calculated
* @param activity Context reference
* @param uploadItem UploadItem whose quality is to be checked
*/
private fun storeImageQuality(
imageResult: Int, uploadItemIndex: Int, activity: Activity, uploadItem: UploadItem
) {
val store = BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize)
val value = store.getString(UPLOAD_QUALITIES_KEY, null)
try {
val jsonObject = value.asJsonObject().apply {
put("UploadItem$uploadItemIndex", imageResult)
}
store.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
} catch (e: Exception) {
Timber.e(e)
}
if (uploadItemIndex == 0) {
if (!isBatteryDialogShowing && !isCategoriesDialogShowing) {
// if battery-optimisation dialog is not being shown, call checkImageQuality
checkImageQuality(uploadItem, uploadItemIndex)
} else {
view.showProgress(false)
}
}
}
/**
* Used to check image quality from stored qualities and display dialogs
*
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
override fun checkImageQuality(uploadItem: UploadItem, index: Int) {
if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
try {
val imageQuality = value.asJsonObject()["UploadItem$index"] as Int
view.showProgress(false)
if (imageQuality == IMAGE_OK) {
uploadItem.hasInvalidLocation = false
uploadItem.imageQuality = imageQuality
} else {
handleBadImage(imageQuality, uploadItem, index)
}
} catch (e: Exception) {
Timber.e(e)
}
}
}
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
*
* @param size Size of uploadableFiles
* @param index Index of the UploadItem which was deleted
*/
override fun updateImageQualitiesJSON(size: Int, index: Int) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
try {
val jsonObject = value.asJsonObject().apply {
for (i in index until (size - 1)) {
put("UploadItem$i", this["UploadItem" + (i + 1)])
}
remove("UploadItem" + (size - 1))
}
basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
} catch (e: Exception) {
Timber.e(e)
}
}
/**
* Handles bad pictures, like too dark, already on wikimedia, downloaded from internet
*
* @param errorCode Error code of the bad image quality
* @param uploadItem UploadItem whose quality is bad
* @param index Index of item whose quality is bad
*/
private fun handleBadImage(
errorCode: Int,
uploadItem: UploadItem, index: Int
) {
Timber.d("Handle bad picture with error code %d", errorCode)
if (errorCode >= 8) { // If location of image and nearby does not match
uploadItem.hasInvalidLocation = true
}
// If image has some other problems, show popup accordingly
if (errorCode != EMPTY_CAPTION && errorCode != FILE_NAME_EXISTS) {
view.showBadImagePopup(errorCode, index, uploadItem)
}
}
/**
* notifies the user that a similar image exists
*/
override fun showSimilarImageFragment(
originalFilePath: String?,
possibleFilePath: String?,
similarImageCoordinates: ImageCoordinates?
) = view.showSimilarImageFragment(originalFilePath, possibleFilePath, similarImageCoordinates)
private fun String?.asJsonObject() = if (this != null) {
JSONObject(this)
} else {
JSONObject()
}
companion object {
private const val UPLOAD_QUALITIES_KEY = "UploadedImagesQualities"
private val WLM_SUPPORTED_COUNTRIES = listOf(
"am", "at", "az", "br", "hr", "sv", "fi", "fr", "de", "gh",
"in", "ie", "il", "mk", "my", "mt", "pk", "pe", "pl", "ru",
"rw", "si", "es", "se", "tw", "ug", "ua", "us"
)
private val DUMMY = Proxy.newProxyInstance(
UploadMediaDetailsContract.View::class.java.classLoader,
arrayOf<Class<*>>(UploadMediaDetailsContract.View::class.java)
) { _: Any?, _: Method?, _: Array<Any?>? -> null } as UploadMediaDetailsContract.View
var presenterCallback: UploadMediaDetailFragmentCallback? = null
/**
* Variable used to determine if the battery-optimisation dialog is being shown or not
*/
var isBatteryDialogShowing: Boolean = false
var isCategoriesDialogShowing: Boolean = false
}
}

View file

@ -208,7 +208,7 @@ object PermissionUtils {
activity.getString(android.R.string.cancel),
{
if (activity is UploadActivity) {
activity.setShowPermissionsDialog(true)
activity.isShowPermissionsDialog = true
}
token.continuePermissionRequest()
},

View file

@ -13,8 +13,8 @@ import com.nhaarman.mockitokotlin2.verify
import fr.free.nrw.commons.CameraPosition
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_ZOOM
import io.reactivex.android.plugins.RxAndroidPlugins
import io.reactivex.schedulers.Schedulers
import org.junit.Assert

View file

@ -223,7 +223,7 @@ class SettingsFragmentUnitTests {
RecentLanguagesAdapter(
context,
listOf(Language("English", "en")),
hashMapOf<String, String>(),
mutableMapOf(),
),
)
val method: Method =

View file

@ -30,7 +30,7 @@ class LanguagesAdapterTest {
private lateinit var context: Context
@Mock
private lateinit var selectedLanguages: HashMap<Integer, String>
private lateinit var selectedLanguages: MutableMap<Int, String>
@Mock
private lateinit var parent: ViewGroup
@ -41,7 +41,7 @@ class LanguagesAdapterTest {
private lateinit var languagesAdapter: LanguagesAdapter
private lateinit var convertView: View
private var selectLanguages: HashMap<Integer, String> = HashMap()
private var selectLanguages: MutableMap<Int, String> = mutableMapOf()
@Before
@Throws(Exception::class)
@ -94,8 +94,8 @@ class LanguagesAdapterTest {
@Test
fun testSelectLanguageNotEmpty() {
selectLanguages[Integer(0)] = "es"
selectLanguages[Integer(1)] = "de"
selectLanguages[0] = "es"
selectLanguages[1] = "de"
languagesAdapter = LanguagesAdapter(context, selectLanguages)
Assertions.assertEquals(false, languagesAdapter.isEnabled(languagesAdapter.getIndexOfLanguageCode("es")))

View file

@ -246,7 +246,7 @@ class UploadMediaDetailAdapterUnitTest {
RecentLanguagesAdapter(
context,
listOf(Language("English", "en")),
hashMapOf<String, String>(),
mutableMapOf(),
),
)
val method: Method =

View file

@ -1,10 +1,12 @@
package fr.free.nrw.commons.upload
import android.net.Uri
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.repository.UploadRepository
@ -24,6 +26,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.MockedStatic
import org.mockito.Mockito
@ -55,7 +58,7 @@ class UploadMediaPresenterTest {
private lateinit var place: Place
@Mock
private var location: LatLng? = null
private lateinit var location: LatLng
@Mock
private lateinit var uploadItem: UploadItem
@ -63,18 +66,12 @@ class UploadMediaPresenterTest {
@Mock
private lateinit var imageCoordinates: ImageCoordinates
@Mock
private lateinit var uploadMediaDetails: List<UploadMediaDetail>
private lateinit var testObservableUploadItem: Observable<UploadItem>
private lateinit var testSingleImageResult: Single<Int>
private lateinit var testScheduler: TestScheduler
private lateinit var mockedCountry: MockedStatic<Coordinates2Country>
@Mock
private lateinit var jsonKvStore: JsonKvStore
@Mock
lateinit var mockActivity: UploadActivity
@ -91,7 +88,6 @@ class UploadMediaPresenterTest {
uploadMediaPresenter =
UploadMediaPresenter(
repository,
jsonKvStore,
testScheduler,
testScheduler,
)
@ -120,10 +116,7 @@ class UploadMediaPresenterTest {
uploadMediaPresenter.receiveImage(uploadableFile, place, location)
verify(view).showProgress(true)
testScheduler.triggerActions()
verify(view).onImageProcessed(
ArgumentMatchers.any(UploadItem::class.java),
ArgumentMatchers.any(Place::class.java),
)
verify(view).onImageProcessed(isA())
}
/**
@ -167,7 +160,7 @@ class UploadMediaPresenterTest {
@Test
fun emptyFileNameTest() {
uploadMediaPresenter.handleCaptionResult(EMPTY_CAPTION, uploadItem)
verify(view).showMessage(ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())
verify(view).showMessage(R.string.add_caption_toast, R.color.color_error)
}
/**
@ -226,12 +219,11 @@ class UploadMediaPresenterTest {
@Test
fun fetchImageAndTitleTest() {
whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
whenever(repository.getUploadItem(ArgumentMatchers.anyInt()))
.thenReturn(uploadItem)
whenever(repository.getUploadItem(ArgumentMatchers.anyInt())).thenReturn(uploadItem)
whenever(uploadItem.uploadMediaDetails).thenReturn(mutableListOf())
uploadMediaPresenter.fetchTitleAndDescription(0)
verify(view).updateMediaDetails(ArgumentMatchers.any())
verify(view).updateMediaDetails(isA())
}
/**
@ -273,12 +265,9 @@ class UploadMediaPresenterTest {
verify(view).showProgress(true)
testScheduler.triggerActions()
val captor: ArgumentCaptor<UploadItem> = ArgumentCaptor.forClass(UploadItem::class.java)
verify(view).onImageProcessed(
captor.capture(),
ArgumentMatchers.any(Place::class.java),
)
val captor = argumentCaptor<UploadItem>()
verify(view).onImageProcessed(captor.capture())
assertEquals("Exptected contry code", "de", captor.value.countryCode)
assertEquals("Exptected contry code", "de", captor.firstValue.countryCode)
}
}

View file

@ -140,6 +140,6 @@ class UploadModelUnitTest {
@Ignore
@Test
fun testSetSelectedExistingDepictions() {
uploadModel.selectedExistingDepictions = listOf("")
uploadModel.selectedExistingDepictions = mutableListOf("")
}
}

View file

@ -1,7 +1,9 @@
package fr.free.nrw.commons.upload
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.category.CategoriesModel
import fr.free.nrw.commons.category.CategoryItem
@ -17,6 +19,7 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
@ -118,7 +121,9 @@ class UploadRepositoryUnitTest {
@Test
fun testGetUploads() {
assertEquals(repository.getUploads(), uploadModel.uploads)
val result = listOf(uploadItem)
whenever(uploadModel.uploads).thenReturn(result)
assertSame(result, repository.getUploads())
}
@Test
@ -136,10 +141,10 @@ class UploadRepositoryUnitTest {
@Test
fun testSearchAll() {
assertEquals(
repository.searchAll("", listOf(), listOf()),
categoriesModel.searchAll("", listOf(), listOf()),
)
val empty = Observable.empty<List<CategoryItem>>()
whenever(categoriesModel.searchAll(any(), any(), any())).thenReturn(empty)
assertSame(empty, repository.searchAll("", listOf(), listOf()))
}
@Test
@ -164,7 +169,9 @@ class UploadRepositoryUnitTest {
@Test
fun testGetLicenses() {
assertEquals(repository.getLicenses(), uploadModel.licenses)
whenever(uploadModel.licenses).thenReturn(listOf())
repository.getLicenses()
verify(uploadModel).licenses
}
@Test
@ -208,10 +215,10 @@ class UploadRepositoryUnitTest {
@Test
fun testGetUploadItemCaseNonNull() {
`when`(uploadModel.items).thenReturn(listOf(uploadItem))
`when`(uploadModel.items).thenReturn(mutableListOf(uploadItem))
assertEquals(
repository.getUploadItem(0),
uploadModel.items[0],
uploadItem,
)
}
@ -220,19 +227,6 @@ class UploadRepositoryUnitTest {
assertEquals(repository.getUploadItem(-1), null)
}
@Test
fun testSetSelectedLicense() {
assertEquals(repository.setSelectedLicense(""), uploadModel.setSelectedLicense(""))
}
@Test
fun testSetSelectedExistingDepictions() {
assertEquals(
repository.setSelectedExistingDepictions(listOf("")),
uploadModel.setSelectedExistingDepictions(listOf("")),
)
}
@Test
fun testOnDepictItemClicked() {
assertEquals(
@ -243,12 +237,14 @@ class UploadRepositoryUnitTest {
@Test
fun testGetSelectedDepictions() {
assertEquals(repository.getSelectedDepictions(), uploadModel.selectedDepictions)
repository.getSelectedDepictions()
verify(uploadModel).selectedDepictions
}
@Test
fun testGetSelectedExistingDepictions() {
assertEquals(repository.getSelectedExistingDepictions(), uploadModel.selectedExistingDepictions)
repository.getSelectedExistingDepictions()
verify(uploadModel).selectedExistingDepictions
}
@Test
@ -324,8 +320,8 @@ class UploadRepositoryUnitTest {
@Test
fun testIsWMLSupportedForThisPlace() {
`when`(uploadModel.items).thenReturn(listOf(uploadItem))
`when`(uploadItem.isWLMUpload).thenReturn(true)
whenever(uploadModel.items).thenReturn(mutableListOf(uploadItem))
whenever(uploadItem.isWLMUpload).thenReturn(true)
assertEquals(
repository.isWMLSupportedForThisPlace(),
true,

View file

@ -34,7 +34,7 @@ import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_ZOOM
import org.junit.Assert
import org.junit.Before
import org.junit.Test
@ -100,7 +100,7 @@ class UploadMediaDetailFragmentUnitTest {
private lateinit var place: Place
@Mock
private var location: fr.free.nrw.commons.location.LatLng? = null
private lateinit var location: LatLng
@Mock
private lateinit var defaultKvStore: JsonKvStore
@ -153,12 +153,6 @@ class UploadMediaDetailFragmentUnitTest {
Assert.assertNotNull(fragment)
}
@Test
@Throws(Exception::class)
fun testSetCallback() {
fragment.setCallback(null)
}
@Test
@Throws(Exception::class)
fun testOnCreate() {
@ -194,7 +188,7 @@ class UploadMediaDetailFragmentUnitTest {
Whitebox.setInternalState(fragment, "presenter", presenter)
val method: Method =
UploadMediaDetailFragment::class.java.getDeclaredMethod(
"init",
"initializeFragment",
)
method.isAccessible = true
method.invoke(fragment)
@ -209,7 +203,7 @@ class UploadMediaDetailFragmentUnitTest {
`when`(callback.totalNumberOfSteps).thenReturn(5)
val method: Method =
UploadMediaDetailFragment::class.java.getDeclaredMethod(
"init",
"initializeFragment",
)
method.isAccessible = true
method.invoke(fragment)
@ -229,22 +223,6 @@ class UploadMediaDetailFragmentUnitTest {
method.invoke(fragment, R.string.media_detail_step_title, R.string.media_details_tooltip)
}
@Test
@Throws(Exception::class)
fun testOnNextButtonClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onNextButtonClicked()
}
@Test
@Throws(Exception::class)
fun testOnPreviousButtonClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onPreviousButtonClicked()
}
@Test
@Throws(Exception::class)
fun testShowSimilarImageFragment() {
@ -258,7 +236,7 @@ class UploadMediaDetailFragmentUnitTest {
fun testOnImageProcessed() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(uploadItem.mediaUri).thenReturn(mediaUri)
fragment.onImageProcessed(uploadItem, place)
fragment.onImageProcessed(uploadItem)
}
@Test
@ -366,7 +344,10 @@ class UploadMediaDetailFragmentUnitTest {
`when`(uploadItem.gpsCoords).thenReturn(imageCoordinates)
val activityResult = ActivityResult(Activity.RESULT_OK, intent)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod("onCameraPosition", ActivityResult::class.java)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod(
"onCameraPosition",
ActivityResult::class.java
)
handleResultMethod.isAccessible = true
handleResultMethod.invoke(fragment, activityResult)
@ -382,7 +363,7 @@ class UploadMediaDetailFragmentUnitTest {
val cameraPosition = Mockito.mock(CameraPosition::class.java)
val latLng = Mockito.mock(LatLng::class.java)
Whitebox.setInternalState(fragment, "callback", callback)
Whitebox.setInternalState(fragment, "fragmentCallback", callback)
Whitebox.setInternalState(cameraPosition, "latitude", latLng.latitude)
Whitebox.setInternalState(cameraPosition, "longitude", latLng.longitude)
Whitebox.setInternalState(fragment, "editableUploadItem", uploadItem)
@ -396,7 +377,10 @@ class UploadMediaDetailFragmentUnitTest {
val activityResult = ActivityResult(Activity.RESULT_OK, intent)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod("onCameraPosition", ActivityResult::class.java)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod(
"onCameraPosition",
ActivityResult::class.java
)
handleResultMethod.isAccessible = true
handleResultMethod.invoke(fragment, activityResult)
@ -407,7 +391,7 @@ class UploadMediaDetailFragmentUnitTest {
@Throws(Exception::class)
fun testUpdateMediaDetails() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.updateMediaDetails(null)
fragment.updateMediaDetails(mock())
}
@Test
@ -417,21 +401,6 @@ class UploadMediaDetailFragmentUnitTest {
fragment.onDestroyView()
}
@Test
@Throws(Exception::class)
fun testOnLlContainerTitleClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.onLlContainerTitleClicked()
}
@Test
@Throws(Exception::class)
fun testOnIbMapClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onIbMapClicked()
}
@Test
@Throws(Exception::class)
fun testOnPrimaryCaptionTextChange() {