From 0e735512bbb4c3b103cc2d17382f0efb319fc4e2 Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Mon, 13 Jan 2025 08:04:09 -0600 Subject: [PATCH] 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 --- .../nrw/commons/contributions/Contribution.kt | 4 +- .../description/DescriptionEditActivity.kt | 21 +- .../locationpicker/LocationPickerActivity.kt | 4 +- .../nrw/commons/media/MediaDetailFragment.kt | 2 +- .../recentlanguages/RecentLanguagesAdapter.kt | 2 +- .../commons/repository/UploadRepository.kt | 12 +- .../free/nrw/commons/upload/FileProcessor.kt | 6 +- .../nrw/commons/upload/LanguagesAdapter.kt | 2 +- .../nrw/commons/upload/UploadActivity.java | 986 ------------------ .../free/nrw/commons/upload/UploadActivity.kt | 947 +++++++++++++++++ .../nrw/commons/upload/UploadBaseFragment.kt | 2 +- .../free/nrw/commons/upload/UploadContract.kt | 3 + .../fr/free/nrw/commons/upload/UploadItem.kt | 1 - .../nrw/commons/upload/UploadMediaDetail.kt | 14 +- .../upload/UploadMediaDetailAdapter.java | 633 ----------- .../upload/UploadMediaDetailAdapter.kt | 563 ++++++++++ .../free/nrw/commons/upload/UploadModel.java | 297 ------ .../fr/free/nrw/commons/upload/UploadModel.kt | 242 +++++ .../nrw/commons/upload/UploadPresenter.kt | 22 +- .../upload/categories/CategoriesPresenter.kt | 2 +- .../categories/UploadCategoriesFragment.kt | 14 +- .../commons/upload/depicts/DepictsFragment.kt | 9 +- .../upload/license/MediaLicenseFragment.kt | 3 +- .../UploadMediaDetailFragment.java | 922 ---------------- .../mediaDetails/UploadMediaDetailFragment.kt | 903 ++++++++++++++++ .../UploadMediaDetailsContract.kt | 32 +- .../mediaDetails/UploadMediaPresenter.java | 547 ---------- .../mediaDetails/UploadMediaPresenter.kt | 441 ++++++++ .../free/nrw/commons/utils/PermissionUtils.kt | 2 +- .../LocationPickerActivityUnitTests.kt | 4 +- .../settings/SettingsFragmentUnitTests.kt | 2 +- .../commons/upload/LanguagesAdapterTest.kt | 8 +- .../UploadMediaDetailAdapterUnitTest.kt | 2 +- .../upload/UploadMediaPresenterTest.kt | 35 +- .../nrw/commons/upload/UploadModelUnitTest.kt | 2 +- .../upload/UploadRepositoryUnitTest.kt | 46 +- .../UploadMediaDetailFragmentUnitTest.kt | 63 +- 37 files changed, 3236 insertions(+), 3564 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt index 84ce0eb9c..92fab56af 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt @@ -101,7 +101,7 @@ data class Contribution constructor( */ fun formatCaptions(uploadMediaDetails: List) = 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) = descriptions - .filter { it.descriptionText.isNotEmpty() } + .filter { !it.descriptionText.isNullOrEmpty() } .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" } } diff --git a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt index 3c6ad8653..942946a6b 100644 --- a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt @@ -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? = null + private var descriptionAndCaptions: MutableList? = 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?) { + private fun initRecyclerView(descriptionAndCaptions: MutableList?) { 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.") }, diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt index 69e4eee64..080bc058d 100644 --- a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt @@ -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 diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index 299f9b3be..66d9d08ef 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -1569,7 +1569,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C mediaDetail: UploadMediaDetail, updatedCaptions: MutableMap ) { - updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText + updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!! media!!.captions = updatedCaptions } diff --git a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesAdapter.kt b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesAdapter.kt index 81ef5533d..42eae100a 100644 --- a/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/recentlanguages/RecentLanguagesAdapter.kt @@ -17,7 +17,7 @@ import java.util.HashMap class RecentLanguagesAdapter constructor( context: Context, var recentLanguages: List, - private val selectedLanguages: HashMap<*, String>, + private val selectedLanguages: MutableMap, ) : ArrayAdapter(context, R.layout.row_item_languages_spinner) { /** * Selected language code in UploadMediaDetailAdapter diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt index f679960b9..9acdde32c 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt @@ -46,7 +46,7 @@ class UploadRepository @Inject constructor( * * @return */ - fun buildContributions(): Observable? { + fun buildContributions(): Observable { return uploadModel.buildContributions() } @@ -69,7 +69,7 @@ class UploadRepository @Inject constructor( * @return */ fun getUploads(): List { - return uploadModel.getUploads() + return uploadModel.uploads } /** @@ -177,7 +177,7 @@ class UploadRepository @Inject constructor( place: Place?, similarImageInterface: SimilarImageInterface?, inAppPictureLocation: LatLng? - ): Observable? { + ): Observable { 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? { + fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single { 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? { + fun getCaptionQuality(uploadItem: UploadItem): Single { return uploadModel.getCaptionQuality(uploadItem) } @@ -275,7 +275,7 @@ class UploadRepository @Inject constructor( * @param selectedExistingDepictions existing depicts */ fun setSelectedExistingDepictions(selectedExistingDepictions: List) { - uploadModel.selectedExistingDepictions = selectedExistingDepictions + uploadModel.selectedExistingDepictions = selectedExistingDepictions.toMutableList() } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt index 6bdcbdda7..617da88a0 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.kt @@ -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, diff --git a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt index fa825d0a6..a0d22009a 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt @@ -23,7 +23,7 @@ import java.util.Locale */ class LanguagesAdapter constructor( context: Context, - private val selectedLanguages: HashMap<*, String>, + private val selectedLanguages: MutableMap, ) : ArrayAdapter(context, R.layout.row_item_languages_spinner) { companion object { /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java deleted file mode 100644 index 01a084ed2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.java +++ /dev/null @@ -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 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 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 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 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 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 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 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: Issue - * - * @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: Issue - * - * @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 fragments; - - public UploadImageAdapter(final FragmentManager fragmentManager) { - super(fragmentManager); - this.fragments = new ArrayList<>(); - } - - public void setFragments(final List 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; - } - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt new file mode 100644 index 000000000..1ae7150e6 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt @@ -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? = 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 = 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 = + 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().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? { + 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, + 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().apply { + addAll(intent.getParcelableArrayListExtra(EXTRA_FILES) ?: emptyList()) + } + 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() + } + + 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 = 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(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? = 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 + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadBaseFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadBaseFragment.kt index cdde44c1d..57e62d572 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadBaseFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadBaseFragment.kt @@ -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 diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt index 04ab02b8e..43adc7f79 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadContract.kt @@ -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) } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.kt index 370ef960a..f357cd112 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadItem.kt @@ -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?, diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt index 3756cee5b..a2b3168ec 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetail.kt @@ -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, ) /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java deleted file mode 100644 index de37e6855..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.java +++ /dev/null @@ -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 { - - RecentLanguagesDao recentLanguagesDao; - - private List uploadMediaDetails; - private Callback callback; - private EventListener eventListener; - - private HashMap 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 voiceInputResultLauncher; - private SelectedVoiceIcon selectedVoiceIcon; - - private RowItemDescriptionBinding binding; - - public UploadMediaDetailAdapter(Fragment fragment, String savedLanguageValue, - RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher 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 uploadMediaDetails, RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher 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 uploadMediaDetails) { - this.uploadMediaDetails = uploadMediaDetails; - selectedLanguages = new HashMap<>(); - notifyDataSetChanged(); - } - - public List 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 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 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 - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.kt new file mode 100644 index 000000000..05ed5f665 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadMediaDetailAdapter.kt @@ -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 { + private var uploadMediaDetails: MutableList + private var selectedLanguages: MutableMap + 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 + private var selectedVoiceIcon: SelectedVoiceIcon? = null + var recentLanguagesDao: RecentLanguagesDao + var callback: Callback? = null + var eventListener: EventListener? = null + var items: List + get() = uploadMediaDetails + set(value) { + uploadMediaDetails = value.toMutableList() + selectedLanguages = mutableMapOf() + notifyDataSetChanged() + } + + + constructor( + fragment: Fragment?, + savedLanguageValue: String, + recentLanguagesDao: RecentLanguagesDao, + voiceInputResultLauncher: ActivityResultLauncher + ) { + uploadMediaDetails = ArrayList() + selectedLanguages = mutableMapOf() + this.savedLanguageValue = savedLanguageValue + this.recentLanguagesDao = recentLanguagesDao + this.fragment = fragment + this.voiceInputResultLauncher = voiceInputResultLauncher + } + + constructor( + activity: Activity?, + savedLanguageValue: String, + uploadMediaDetails: MutableList, + recentLanguagesDao: RecentLanguagesDao, + voiceInputResultLauncher: ActivityResultLauncher + ) { + 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(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(R.id.search_language) + val listView = + dialog.findViewById(R.id.language_list) + languageHistoryListView = + dialog.findViewById(R.id.language_history_list) + recentLanguagesTextView = + dialog.findViewById(R.id.recent_searches) + separator = + dialog.findViewById(R.id.separator) + setUpRecentLanguagesSection(recentLanguages) + + 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) { + 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(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(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(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(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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java deleted file mode 100644 index dfb7ae794..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java +++ /dev/null @@ -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 licenses; - private final Context context; - private String license; - private final Map licensesByName; - private final List items = new ArrayList<>(); - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - private final SessionManager sessionManager; - private final FileProcessor fileProcessor; - private final ImageProcessingService imageProcessingService; - private final List selectedCategories = new ArrayList<>(); - private final List selectedDepictions = new ArrayList<>(); - /** - * Existing depicts which are selected - */ - private List selectedExistingDepictions = new ArrayList<>(); - - @Inject - UploadModel(@Named("licenses") final List licenses, - @Named("default_preferences") final JsonKvStore store, - @Named("licenses_by_name") final Map 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 selectedCategories) { - this.selectedCategories.clear(); - this.selectedCategories.addAll(selectedCategories); - } - - /** - * pre process a one item at a time - */ - public Observable 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 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 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 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 getUploads() { - return items; - } - - public List 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 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 iterator = items.iterator(); - while (iterator.hasNext()) { - if (iterator.next().getMediaUri().toString().contains(filePath)) { - iterator.remove(); - break; - } - } - if (items.isEmpty()) { - cleanUp(); - } - } - - public List 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 depictsList = new ArrayList<>(); - depictsList.add(depictedItem.getId()); - depictsList.addAll(media.getDepictionIds()); - media.setDepictionIds(depictsList); - } - } else { - selectedDepictions.remove(depictedItem); - } - } - } - } - - @NotNull - private List newListOf(final List 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 getSelectedDepictions() { - return selectedDepictions; - } - - /** - * Provides selected existing depicts - * - * @return selected existing depicts - */ - public List getSelectedExistingDepictions() { - return selectedExistingDepictions; - } - - /** - * Initialize existing depicts - * - * @param selectedExistingDepictions existing depicts - */ - public void setSelectedExistingDepictions(final List selectedExistingDepictions) { - this.selectedExistingDepictions = selectedExistingDepictions; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt new file mode 100644 index 000000000..c7ffe06f7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt @@ -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, + @param:Named("default_preferences") val store: JsonKvStore, + @param:Named("licenses_by_name") val licensesByName: Map, + 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 = mutableListOf() + val compositeDisposable: CompositeDisposable = CompositeDisposable() + val selectedCategories: MutableList = mutableListOf() + val selectedDepictions: MutableList = mutableListOf() + + /** + * Existing depicts which are selected + */ + var selectedExistingDepictions: MutableList = mutableListOf() + val count: Int + get() = items.size + + val uploads: List + 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) { + selectedCategories.clear() + selectedCategories.addAll(categories) + } + + /** + * pre process a one item at a time + */ + fun preProcessImage( + uploadableFile: UploadableFile?, + place: Place?, + similarImageInterface: SimilarImageInterface?, + inAppPictureLocation: LatLng? + ): Observable = 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 = + 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 = + 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 = + 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 { + 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().apply { + add(depictedItem.id) + addAll(media.depictionIds) + } + } + } else { + selectedDepictions.remove(depictedItem) + } + } + } + } + + fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int) { + fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates) + items[uploadItemIndex].gpsCoords = imageCoordinates + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt index 361ac1cee..9ee8fb483 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadPresenter.kt @@ -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 { 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) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt index dbeeae6ff..7536cad75 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/CategoriesPresenter.kt @@ -140,7 +140,7 @@ class CategoriesPresenter */ private fun getImageTitleList(): List = repository.getUploads() - .map { it.uploadMediaDetails[0].captionText } + .map { it.uploadMediaDetails[0].captionText!! } .filterNot { TextUtils.isEmpty(it) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt index 584b87963..80037a028 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt @@ -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)) } } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.kt index 99fd52571..39bcabb46 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/depicts/DepictsFragment.kt @@ -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) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.kt index 5ba61ab75..0415d3270 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/license/MediaLicenseFragment.kt @@ -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) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java deleted file mode 100644 index 884ad9831..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.java +++ /dev/null @@ -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 startForResult = registerForActivityResult( - new StartActivityForResult(), result -> { - onCameraPosition(result); - }); - - private final ActivityResultLauncher startForEditActivityResult = registerForActivityResult( - new StartActivityForResult(), result -> { - onEditActivityResult(result); - } - ); - - private final ActivityResultLauncher 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 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 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 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) uploadMediaDetailAdapter.getItems()); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - binding = null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt new file mode 100644 index 000000000..92a46b92a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt @@ -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( + ActivityResultContracts.StartActivityForResult(), ::onCameraPosition) + + private val startForEditActivityResult = registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), ::onEditActivityResult) + + private val voiceInputResultLauncher = registerForActivityResult( + 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(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(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) { + 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): 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" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt index 88dad8b93..c368b96ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailsContract.kt @@ -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?) + fun updateMediaDetails(uploadMediaDetails: List) - fun displayAddLocationDialog(runnable: Runnable?) + fun displayAddLocationDialog(runnable: Runnable) + + fun showBadImagePopup(errorCode: Int, index: Int, uploadItem: UploadItem) } interface UserActionListener : BasePresenter { + fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore) + fun receiveImage( uploadableFile: UploadableFile?, place: Place?, @@ -59,7 +64,7 @@ interface UploadMediaDetailsContract { ) fun setUploadMediaDetails( - uploadMediaDetails: List?, + uploadMediaDetails: List, 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) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java deleted file mode 100644 index 35d281201..000000000 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.java +++ /dev/null @@ -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 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 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 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 Locale.getISOCountries() - * 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 getCountryNamesAndCodes(){ - final Map 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 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 deepCopy(final List uploadMediaDetails) { - final ArrayList 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 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 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 - ); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt new file mode 100644 index 000000000..55cead370 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt @@ -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 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, + 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) = + 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>(UploadMediaDetailsContract.View::class.java) + ) { _: Any?, _: Method?, _: Array? -> 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt index f5a98dc5d..daf158fc1 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.kt @@ -208,7 +208,7 @@ object PermissionUtils { activity.getString(android.R.string.cancel), { if (activity is UploadActivity) { - activity.setShowPermissionsDialog(true) + activity.isShowPermissionsDialog = true } token.continuePermissionRequest() }, diff --git a/app/src/test/kotlin/fr/free/nrw/commons/locationpicker/LocationPickerActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/locationpicker/LocationPickerActivityUnitTests.kt index b407dc4a3..bbf416e2c 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/locationpicker/LocationPickerActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/locationpicker/LocationPickerActivityUnitTests.kt @@ -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 diff --git a/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt index 5a6d27e1b..acd56c5f4 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/settings/SettingsFragmentUnitTests.kt @@ -223,7 +223,7 @@ class SettingsFragmentUnitTests { RecentLanguagesAdapter( context, listOf(Language("English", "en")), - hashMapOf(), + mutableMapOf(), ), ) val method: Method = diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt index f272a8288..f1b0fe7b8 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt @@ -30,7 +30,7 @@ class LanguagesAdapterTest { private lateinit var context: Context @Mock - private lateinit var selectedLanguages: HashMap + private lateinit var selectedLanguages: MutableMap @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 = HashMap() + private var selectLanguages: MutableMap = 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"))) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaDetailAdapterUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaDetailAdapterUnitTest.kt index c6bed9bc5..7cc59b78d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaDetailAdapterUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaDetailAdapterUnitTest.kt @@ -246,7 +246,7 @@ class UploadMediaDetailAdapterUnitTest { RecentLanguagesAdapter( context, listOf(Language("English", "en")), - hashMapOf(), + mutableMapOf(), ), ) val method: Method = diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt index 5793d6e79..da1443834 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadMediaPresenterTest.kt @@ -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 - private lateinit var testObservableUploadItem: Observable private lateinit var testSingleImageResult: Single private lateinit var testScheduler: TestScheduler private lateinit var mockedCountry: MockedStatic - @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 = ArgumentCaptor.forClass(UploadItem::class.java) - verify(view).onImageProcessed( - captor.capture(), - ArgumentMatchers.any(Place::class.java), - ) + val captor = argumentCaptor() + verify(view).onImageProcessed(captor.capture()) - assertEquals("Exptected contry code", "de", captor.value.countryCode) + assertEquals("Exptected contry code", "de", captor.firstValue.countryCode) } } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadModelUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadModelUnitTest.kt index fb1f07e33..09f1acb58 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadModelUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadModelUnitTest.kt @@ -140,6 +140,6 @@ class UploadModelUnitTest { @Ignore @Test fun testSetSelectedExistingDepictions() { - uploadModel.selectedExistingDepictions = listOf("") + uploadModel.selectedExistingDepictions = mutableListOf("") } } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt index ac01d237f..9c022c936 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadRepositoryUnitTest.kt @@ -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>() + 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, diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragmentUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragmentUnitTest.kt index cbd1f8ca7..a37bcc927 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragmentUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragmentUnitTest.kt @@ -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) @@ -394,9 +375,12 @@ class UploadMediaDetailFragmentUnitTest { `when`(latLng.longitude).thenReturn(0.0) `when`(uploadItem.gpsCoords).thenReturn(imageCoordinates) - val activityResult = ActivityResult(Activity.RESULT_OK,intent) + 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() {