From 12cadd018691caf1ecae7790ab5f050b4ac874ba Mon Sep 17 00:00:00 2001 From: Sujal Date: Fri, 7 Feb 2025 06:33:38 +0530 Subject: [PATCH] Migrated contributions folder Files from java to kotlin (#6176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename .java to .kt * Migrated ContributionController * Rename .java to .kt * Migrated ContributionDao * Rename .java to .kt * Migrated ContributionsContract,ContributionFragment,ContributionListAdapter,ContributionsListContract from java to Kotlin * Rename .java to .kt * converted/Migrated * converted/Migrated * Rename .java to .kt * Migrated ContributionController * Rename .java to .kt * Migrated ContributionDao * Rename .java to .kt * Migrated ContributionsContract,ContributionFragment,ContributionListAdapter,ContributionsListContract from java to Kotlin * Rename .java to .kt * Show placeholder and display depiction section when no depictions are available (#6163) (#6165) * corrected * corrected * Update MediaDetailFragment.kt Spelling correction * Migrated AboutActivity from Java to Kotlin (#6158) * Rename Constants to Follow Kotlin Naming Conventions >This PR refactors constant names in the project to adhere to Kotlin's UPPERCASE_SNAKE_CASE naming convention, improving code readability and maintaining consistency across the codebase. >Renamed the following constants in LoginActivity: >saveProgressDialog → SAVE_PROGRESS_DIALOG >saveErrorMessage → SAVE_ERROR_MESSAGE >saveUsername → SAVE_USERNAME >savePassword → SAVE_PASSWORD >Updated all references to these constants throughout the project. * Update Project_Default.xml * Refactor variable names to adhere to naming conventions Renamed variables to use camel case: -UPLOAD_COUNT_THRESHOLD → uploadCountThreshold -REVERT_PERCENTAGE_FOR_MESSAGE → revertPercentageForMessage -REVERT_SHARED_PREFERENCE → revertSharedPreference -UPLOAD_SHARED_PREFERENCE → uploadSharedPreference Renamed variables with uppercase initials to lowercase for alignment with Kotlin conventions: -Latitude → latitude -Longitude → longitude -Accuracy → accuracy Refactored the following variable names: -NUMBER_OF_QUESTIONS → numberOfQuestions -MULTIPLIER_TO_GET_PERCENTAGE → multiplierToGetPercentage * Refactor Dialog View Initialization with Null-Safe Calls This PR refactors the dialog setup code in CustomSelectorActivity to improve safety and readability by replacing explicit casts with null-safe generic calls for findViewById. >Replaced explicit casting (as Button and as TextView) with the generic findViewById() method for improved type safety. >Added null-safety (?.) to avoid potential crashes if a view is not found in the dialog layout. why changed:- >Prevents runtime crashes caused by NullPointerException when a view is missing in the layout. * Refactor Unit Test: Replace Unsafe Casting with Type-Safe Mocking for findViewById >PR refactors the unit test for NearbyParentFragment by replacing unsafe casting in the findViewById mocking statements with type-safe >Ensured all findViewById mocks now use a consistent, type-safe format (findViewById(...)) to reduce verbosity and potential casting errors. >Verified the functionality of prepareViewsForSheetPosition remains unchanged, ensuring no regression in test behavior. * Update NearbyParentFragmentUnitTest.kt * Refactor: Rename Constants to Follow CamelCase Naming Convention >Updated all constant variable names to follow the camelCase naming convention, removing underscores in the middle or end. >Ensured variable names remain descriptive and align with code readability best practices. * Replace private val with const val for URL constants in QuizController * Renaming the constant to use UPPER_SNAKE_CASE * Renaming the constant to use UPPER_SNAKE_CASE * Update Done * **Refactor: Convert `minimumThresholdForSwipe` to a compile-time constant** * Convert AboutActivity from Java to Kotlin This PR converts the AboutActivity class from Java to Kotlin >Testing: >Verified all functionalities of the AboutActivity, including toolbar setup, intent launches, and dialog interactions, to ensure behavior remains consistent post-conversion. >Successfully ran unit tests for AboutActivity to confirm the correctness of methods and logic. * Thank you for the suggestion! Since these methods all take a single View parameter, replacing them with method references is a great way to simplify the code and improve readability. I'll updated the code accordingly. Added a TODO in the code as a reminder to refactor this in the future. --------- Co-authored-by: Nicolas Raoul * Localisation updates from https://translatewiki.net. * Feat: Make it smoother to switch between nearby and explore maps (#6164) * Nearby: Add 'Show in Explore' 3-dots menu item * MainActivity: Add methods to pass extras between Nearby and Explore * MainActivity: Extend loadFragment() to support passing fragment arguments * Nearby: Add ability to navigate to Explore fragment on 'Show in Explore' click * Explore: Read fragment arguments for Nearby map data and update Explore map if present * Explore: Add 'Show in Nearby' 3-dots menu item. Only visible when Map tab is selected * Explore: On 'Show in Nearby' click, navigate to Nearby fragment, passing map data as fragment args * Nearby: Read fragment arguments for Explore map data and update Nearby map if present * MainActivity: Fix memory leaks when navigating between bottom nav destinations * Explore: Fix crashes caused by unattached map fragment * Refactor code to pass unit tests * Explore: Format javadocs --------- Co-authored-by: Nicolas Raoul * Localisation updates from https://translatewiki.net. * enhance spammy category filter (#6167) Signed-off-by: parneet-guraya * Localisation updates from https://translatewiki.net. * correction * correction * correction * GitHub workflow to build betaDebug (#6174) * [Bug fix] Check if duplicate exist using both original and modified file's checksum (#6169) * check original file's SHA too along with modified one Signed-off-by: parneet-guraya * fix tests Signed-off-by: parneet-guraya --------- Signed-off-by: parneet-guraya * Add multiline input for caption and description (#6173) * allow multiple lines for description/caption * make caption multiline too --------- Co-authored-by: Nicolas Raoul * correction --------- Signed-off-by: parneet-guraya Co-authored-by: Akshay Komar <146421342+Akshaykomar890@users.noreply.github.com> Co-authored-by: Nicolas Raoul Co-authored-by: translatewiki.net Co-authored-by: Ifeoluwa Andrew Omole Co-authored-by: Parneet Singh <111801812+parneet-guraya@users.noreply.github.com> Co-authored-by: Matija Nalis --- .../contributions/ContributionController.java | 405 ------- .../contributions/ContributionController.kt | 474 +++++++++ .../contributions/ContributionDao.java | 145 --- .../commons/contributions/ContributionDao.kt | 148 +++ .../contributions/ContributionViewHolder.java | 171 --- .../contributions/ContributionViewHolder.kt | 152 +++ .../contributions/ContributionsContract.java | 23 - .../contributions/ContributionsContract.kt | 19 + .../contributions/ContributionsFragment.java | 940 ----------------- .../contributions/ContributionsFragment.kt | 998 ++++++++++++++++++ .../ContributionsListAdapter.java | 77 -- .../contributions/ContributionsListAdapter.kt | 72 ++ .../ContributionsListContract.java | 25 - .../ContributionsListContract.kt | 21 + .../ContributionsListFragment.java | 534 ---------- .../ContributionsListFragment.kt | 551 ++++++++++ .../ContributionsListPresenter.java | 112 -- .../ContributionsListPresenter.kt | 91 ++ .../ContributionsLocalDataSource.java | 131 --- .../ContributionsLocalDataSource.kt | 121 +++ .../contributions/ContributionsModule.java | 15 - .../contributions/ContributionsModule.kt | 16 + .../contributions/ContributionsPresenter.java | 97 -- .../contributions/ContributionsPresenter.kt | 88 ++ .../ContributionsProvidesModule.kt | 28 + .../ContributionsRepository.java | 112 -- .../contributions/ContributionsRepository.kt | 102 ++ .../commons/contributions/MainActivity.java | 550 ---------- .../nrw/commons/contributions/MainActivity.kt | 567 ++++++++++ .../contributions/SetWallpaperWorker.java | 126 --- .../contributions/SetWallpaperWorker.kt | 113 ++ .../contributions/UnswipableViewPager.java | 31 - .../contributions/UnswipableViewPager.kt | 22 + .../WikipediaInstructionsDialogFragment.kt | 2 +- .../commons/di/CommonsApplicationComponent.kt | 2 + .../di/CommonsDaggerSupportFragment.kt | 9 +- .../explore/map/ExploreMapFragment.java | 2 +- .../free/nrw/commons/filepicker/FilePicker.kt | 2 +- .../nrw/commons/media/MediaDetailFragment.kt | 2 +- .../free/nrw/commons/navtab/NavTabLayout.kt | 4 +- .../fragments/NearbyParentFragment.java | 20 +- .../commons/repository/UploadRepository.kt | 2 +- .../commons/upload/PendingUploadsPresenter.kt | 13 +- .../free/nrw/commons/upload/UploadActivity.kt | 2 +- .../categories/UploadCategoriesFragment.kt | 5 +- .../nrw/commons/upload/worker/UploadWorker.kt | 2 +- .../ContributionViewHolderUnitTests.kt | 9 +- .../ContributionsListFragmentUnitTests.kt | 2 +- .../contributions/MainActivityUnitTests.kt | 4 +- 49 files changed, 3630 insertions(+), 3529 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java deleted file mode 100644 index 65604a7e0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ /dev/null @@ -1,405 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - -import android.Manifest.permission; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.widget.Toast; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.paging.DataSource.Factory; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.filepicker.DefaultCallback; -import fr.free.nrw.commons.filepicker.FilePicker; -import fr.free.nrw.commons.filepicker.FilePicker.ImageSource; -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.location.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.UploadActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -@Singleton -public class ContributionController { - - public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads"; - private final JsonKvStore defaultKvStore; - private LatLng locationBeforeImageCapture; - private boolean isInAppCameraUpload; - public LocationPermissionCallback locationPermissionCallback; - private LocationPermissionsHelper locationPermissionsHelper; - // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - // LiveData> failedAndPendingContributionList; - LiveData> pendingContributionList; - LiveData> failedContributionList; - - @Inject - LocationServiceManager locationManager; - - @Inject - ContributionsRepository repository; - - @Inject - public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) { - this.defaultKvStore = defaultKvStore; - } - - /** - * Check for permissions and initiate camera click - */ - public void initiateCameraPick(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); - if (!useExtStorage) { - initiateCameraUpload(activity, resultLauncher); - return; - } - - PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> { - if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { - defaultKvStore.putBoolean("inAppCameraFirstRun", false); - askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher); - } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - }, - R.string.storage_permission_title, - R.string.write_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE()); - } - - /** - * Asks users to provide location access - * - * @param activity - */ - private void createDialogsAndHandleLocationPermissions(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - locationPermissionCallback = new LocationPermissionCallback() { - @Override - public void onLocationPermissionDenied(String toastMessage) { - Toast.makeText( - activity, - toastMessage, - Toast.LENGTH_LONG - ).show(); - initiateCameraUpload(activity, resultLauncher); - } - - @Override - public void onLocationPermissionGranted() { - if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { - showLocationOffDialog(activity, R.string.in_app_camera_needs_location, - R.string.in_app_camera_location_unavailable, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - } - }; - - locationPermissionsHelper = new LocationPermissionsHelper( - activity, locationManager, locationPermissionCallback); - if (inAppCameraLocationPermissionLauncher != null) { - inAppCameraLocationPermissionLauncher.launch( - new String[]{permission.ACCESS_FINE_LOCATION}); - } - - } - - /** - * Shows a dialog alerting the user about location services being off and asking them to turn it - * on - * TODO: Add a seperate callback in LocationPermissionsHelper for this. - * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 - * - * @param activity Activity reference - * @param dialogTextResource Resource id of text to be shown in dialog - * @param toastTextResource Resource id of text to be shown in toast - * @param resultLauncher - */ - private void showLocationOffDialog(Activity activity, int dialogTextResource, - int toastTextResource, ActivityResultLauncher resultLauncher) { - DialogUtil - .showAlertDialog(activity, - activity.getString(R.string.ask_to_turn_location_on), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> locationPermissionsHelper.openLocationSettings(activity), - () -> { - Toast.makeText(activity, activity.getString(toastTextResource), - Toast.LENGTH_LONG).show(); - initiateCameraUpload(activity, resultLauncher); - } - ); - } - - public void handleShowRationaleFlowCameraLocation(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), - activity.getString(R.string.in_app_camera_location_permission_rationale), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - }, - () -> locationPermissionCallback.onLocationPermissionDenied( - activity.getString(R.string.in_app_camera_location_permission_denied)), - null - ); - } - - /** - * Suggest user to attach location information with pictures. If the user selects "Yes", then: - *

- * Location is taken from the EXIF if the default camera application does not redact location - * tags. - *

- * Otherwise, if the EXIF metadata does not have location information, then location captured by - * the app is used - * - * @param activity - */ - private void askUserToAllowLocationAccess(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - DialogUtil.showAlertDialog(activity, - activity.getString(R.string.in_app_camera_location_permission_title), - activity.getString(R.string.in_app_camera_location_access_explanation), - activity.getString(R.string.option_allow), - activity.getString(R.string.option_dismiss), - () -> { - defaultKvStore.putBoolean("inAppCameraLocationPref", true); - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - }, - () -> { - ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied); - defaultKvStore.putBoolean("inAppCameraLocationPref", false); - initiateCameraUpload(activity, resultLauncher); - }, - null - ); - } - - /** - * Initiate gallery picker - */ - public void initiateGalleryPick(final Activity activity, ActivityResultLauncher resultLauncher, final boolean allowMultipleUploads) { - initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads); - } - - /** - * Initiate gallery picker with permission - */ - public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher resultLauncher) { - setPickerConfiguration(activity, true); - - PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> FilePicker.openCustomSelector(activity, resultLauncher, 0), - R.string.storage_permission_title, - R.string.write_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE()); - } - - - /** - * Open chooser for gallery uploads - */ - private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher resultLauncher, - final boolean allowMultipleUploads) { - setPickerConfiguration(activity, allowMultipleUploads); - FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred()); - } - - /** - * Sets configuration for file picker - */ - private void setPickerConfiguration(Activity activity, - boolean allowMultipleUploads) { - boolean copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true); - FilePicker.configuration(activity) - .setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage) - .setAllowMultiplePickInGallery(allowMultipleUploads); - } - - /** - * Initiate camera upload by opening camera - */ - private void initiateCameraUpload(Activity activity, ActivityResultLauncher resultLauncher) { - setPickerConfiguration(activity, false); - if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { - locationBeforeImageCapture = locationManager.getLastLocation(); - } - isInAppCameraUpload = true; - FilePicker.openCameraForImage(activity, resultLauncher, 0); - } - - private boolean isDocumentPhotoPickerPreferred(){ - return defaultKvStore.getBoolean( - "openDocumentPhotoPickerPref", true); - } - - public void onPictureReturnedFromGallery(ActivityResult result, Activity activity, FilePicker.Callbacks callbacks){ - - if(isDocumentPhotoPickerPreferred()){ - FilePicker.onPictureReturnedFromDocuments(result, activity, callbacks); - } else { - FilePicker.onPictureReturnedFromGallery(result, activity, callbacks); - } - } - - public void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks); - } - - public void onPictureReturnedFromCamera(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - FilePicker.onPictureReturnedFromCamera(result, activity, callbacks); - } - - /** - * Attaches callback for file picker. - */ - public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) { - - handleActivityResult.onHandleActivityResult(new DefaultCallback() { - - @Override - public void onCanceled(final ImageSource source, final int type) { - super.onCanceled(source, type); - defaultKvStore.remove(PLACE_OBJECT); - } - - @Override - public void onImagePickerError(Exception e, FilePicker.ImageSource source, - int type) { - ViewUtil.showShortToast(activity, R.string.error_occurred_in_picking_images); - } - - @Override - public void onImagesPicked(@NonNull List imagesFiles, - FilePicker.ImageSource source, int type) { - Intent intent = handleImagesPicked(activity, imagesFiles); - activity.startActivity(intent); - } - }); - } - - public List handleExternalImagesPicked(Activity activity, - Intent data) { - return FilePicker.handleExternalImagesPicked(data, activity); - } - - /** - * Returns intent to be passed to upload activity Attaches place object for nearby uploads and - * location before image capture if in-app camera is used - */ - private Intent handleImagesPicked(Context context, - List imagesFiles) { - Intent shareIntent = new Intent(context, UploadActivity.class); - shareIntent.setAction(ACTION_INTERNAL_UPLOADS); - shareIntent - .putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, new ArrayList<>(imagesFiles)); - Place place = defaultKvStore.getJson(PLACE_OBJECT, Place.class); - - if (place != null) { - shareIntent.putExtra(PLACE_OBJECT, place); - } - - if (locationBeforeImageCapture != null) { - shareIntent.putExtra( - UploadActivity.LOCATION_BEFORE_IMAGE_CAPTURE, - locationBeforeImageCapture); - } - - shareIntent.putExtra( - UploadActivity.IN_APP_CAMERA_UPLOAD, - isInAppCameraUpload - ); - isInAppCameraUpload = false; // reset the flag for next use - return shareIntent; - } - - /** - * Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it - * populates the `pendingContributionList`. - **/ - void getPendingContributions() { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - factory = repository.fetchContributionsWithStates( - Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, - Contribution.STATE_PAUSED)); - - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - pendingContributionList = livePagedListBuilder.build(); - } - - /** - * Fetches the contributions with the state "FAILED" and populates the - * `failedContributionList`. - **/ - void getFailedContributions() { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - factory = repository.fetchContributionsWithStates( - Collections.singletonList(Contribution.STATE_FAILED)); - - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - failedContributionList = livePagedListBuilder.build(); - } - - /** - * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - * Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and - * then it populates the `failedAndPendingContributionList`. - **/ -// void getFailedAndPendingContributions() { -// final PagedList.Config pagedListConfig = -// (new PagedList.Config.Builder()) -// .setPrefetchDistance(50) -// .setPageSize(10).build(); -// Factory factory; -// factory = repository.fetchContributionsWithStates( -// Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, -// Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); -// -// LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, -// pagedListConfig); -// failedAndPendingContributionList = livePagedListBuilder.build(); -// } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt new file mode 100644 index 000000000..296391c6d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt @@ -0,0 +1,474 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.LiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import fr.free.nrw.commons.R +import fr.free.nrw.commons.filepicker.DefaultCallback +import fr.free.nrw.commons.filepicker.FilePicker +import fr.free.nrw.commons.filepicker.FilePicker.HandleActivityResult +import fr.free.nrw.commons.filepicker.FilePicker.configuration +import fr.free.nrw.commons.filepicker.FilePicker.handleExternalImagesPicked +import fr.free.nrw.commons.filepicker.FilePicker.onPictureReturnedFromDocuments +import fr.free.nrw.commons.filepicker.FilePicker.openCameraForImage +import fr.free.nrw.commons.filepicker.FilePicker.openCustomSelector +import fr.free.nrw.commons.filepicker.FilePicker.openGallery +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.location.LocationPermissionsHelper +import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.upload.UploadActivity +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.ViewUtil.showLongToast +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT +import java.util.Arrays +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ContributionController @Inject constructor(@param:Named("default_preferences") private val defaultKvStore: JsonKvStore) { + private var locationBeforeImageCapture: LatLng? = null + private var isInAppCameraUpload = false + @JvmField + var locationPermissionCallback: LocationPermissionCallback? = null + private var locationPermissionsHelper: LocationPermissionsHelper? = null + + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // LiveData> failedAndPendingContributionList; + @JvmField + var pendingContributionList: LiveData>? = null + @JvmField + var failedContributionList: LiveData>? = null + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var repository: ContributionsRepository? = null + + /** + * Check for permissions and initiate camera click + */ + fun initiateCameraPick( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + resultLauncher: ActivityResultLauncher + ) { + val useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true) + if (!useExtStorage) { + initiateCameraUpload(activity, resultLauncher) + return + } + + checkPermissionsAndPerformAction( + activity, + { + if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { + defaultKvStore.putBoolean("inAppCameraFirstRun", false) + askUserToAllowLocationAccess( + activity, + inAppCameraLocationPermissionLauncher, + resultLauncher + ) + } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { + createDialogsAndHandleLocationPermissions( + activity, + inAppCameraLocationPermissionLauncher, resultLauncher + ) + } else { + initiateCameraUpload(activity, resultLauncher) + } + }, + R.string.storage_permission_title, + R.string.write_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + + /** + * Asks users to provide location access + * + * @param activity + */ + private fun createDialogsAndHandleLocationPermissions( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>?, + resultLauncher: ActivityResultLauncher + ) { + locationPermissionCallback = object : LocationPermissionCallback { + override fun onLocationPermissionDenied(toastMessage: String) { + Toast.makeText( + activity, + toastMessage, + Toast.LENGTH_LONG + ).show() + initiateCameraUpload(activity, resultLauncher) + } + + override fun onLocationPermissionGranted() { + if (!locationPermissionsHelper!!.isLocationAccessToAppsTurnedOn()) { + showLocationOffDialog( + activity, R.string.in_app_camera_needs_location, + R.string.in_app_camera_location_unavailable, resultLauncher + ) + } else { + initiateCameraUpload(activity, resultLauncher) + } + } + } + + locationPermissionsHelper = LocationPermissionsHelper( + activity, locationManager!!, locationPermissionCallback + ) + inAppCameraLocationPermissionLauncher?.launch( + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + } + + /** + * Shows a dialog alerting the user about location services being off and asking them to turn it + * on + * TODO: Add a seperate callback in LocationPermissionsHelper for this. + * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 + * + * @param activity Activity reference + * @param dialogTextResource Resource id of text to be shown in dialog + * @param toastTextResource Resource id of text to be shown in toast + * @param resultLauncher + */ + private fun showLocationOffDialog( + activity: Activity, dialogTextResource: Int, + toastTextResource: Int, resultLauncher: ActivityResultLauncher + ) { + showAlertDialog(activity, + activity.getString(R.string.ask_to_turn_location_on), + activity.getString(dialogTextResource), + activity.getString(R.string.title_app_shortcut_setting), + activity.getString(R.string.cancel), + { locationPermissionsHelper!!.openLocationSettings(activity) }, + { + Toast.makeText( + activity, activity.getString(toastTextResource), + Toast.LENGTH_LONG + ).show() + initiateCameraUpload(activity, resultLauncher) + } + ) + } + + fun handleShowRationaleFlowCameraLocation( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>?, + resultLauncher: ActivityResultLauncher + ) { + showAlertDialog( + activity, activity.getString(R.string.location_permission_title), + activity.getString(R.string.in_app_camera_location_permission_rationale), + activity.getString(android.R.string.ok), + activity.getString(android.R.string.cancel), + { + createDialogsAndHandleLocationPermissions( + activity, + inAppCameraLocationPermissionLauncher, resultLauncher + ) + }, + { + locationPermissionCallback!!.onLocationPermissionDenied( + activity.getString(R.string.in_app_camera_location_permission_denied) + ) + }, + null + ) + } + + /** + * Suggest user to attach location information with pictures. If the user selects "Yes", then: + * + * + * Location is taken from the EXIF if the default camera application does not redact location + * tags. + * + * + * Otherwise, if the EXIF metadata does not have location information, then location captured by + * the app is used + * + * @param activity + */ + private fun askUserToAllowLocationAccess( + activity: Activity, + inAppCameraLocationPermissionLauncher: ActivityResultLauncher>, + resultLauncher: ActivityResultLauncher + ) { + showAlertDialog( + activity, + activity.getString(R.string.in_app_camera_location_permission_title), + activity.getString(R.string.in_app_camera_location_access_explanation), + activity.getString(R.string.option_allow), + activity.getString(R.string.option_dismiss), + { + defaultKvStore.putBoolean("inAppCameraLocationPref", true) + createDialogsAndHandleLocationPermissions( + activity, + inAppCameraLocationPermissionLauncher, resultLauncher + ) + }, + { + showLongToast(activity, R.string.in_app_camera_location_permission_denied) + defaultKvStore.putBoolean("inAppCameraLocationPref", false) + initiateCameraUpload(activity, resultLauncher) + }, + null + ) + } + + /** + * Initiate gallery picker + */ + fun initiateGalleryPick( + activity: Activity, + resultLauncher: ActivityResultLauncher, + allowMultipleUploads: Boolean + ) { + initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads) + } + + /** + * Initiate gallery picker with permission + */ + fun initiateCustomGalleryPickWithPermission( + activity: Activity, + resultLauncher: ActivityResultLauncher + ) { + setPickerConfiguration(activity, true) + + checkPermissionsAndPerformAction( + activity, + { openCustomSelector(activity, resultLauncher, 0) }, + R.string.storage_permission_title, + R.string.write_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + + + /** + * Open chooser for gallery uploads + */ + private fun initiateGalleryUpload( + activity: Activity, resultLauncher: ActivityResultLauncher, + allowMultipleUploads: Boolean + ) { + setPickerConfiguration(activity, allowMultipleUploads) + openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred) + } + + /** + * Sets configuration for file picker + */ + private fun setPickerConfiguration( + activity: Activity, + allowMultipleUploads: Boolean + ) { + val copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true) + configuration(activity) + .setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage) + .setAllowMultiplePickInGallery(allowMultipleUploads) + } + + /** + * Initiate camera upload by opening camera + */ + private fun initiateCameraUpload( + activity: Activity, + resultLauncher: ActivityResultLauncher + ) { + setPickerConfiguration(activity, false) + if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { + locationBeforeImageCapture = locationManager!!.getLastLocation() + } + isInAppCameraUpload = true + openCameraForImage(activity, resultLauncher, 0) + } + + private val isDocumentPhotoPickerPreferred: Boolean + get() = defaultKvStore.getBoolean( + "openDocumentPhotoPickerPref", true + ) + + fun onPictureReturnedFromGallery( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + if (isDocumentPhotoPickerPreferred) { + onPictureReturnedFromDocuments(result, activity, callbacks) + } else { + FilePicker.onPictureReturnedFromGallery(result, activity, callbacks) + } + } + + fun onPictureReturnedFromCustomSelector( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks) + } + + fun onPictureReturnedFromCamera( + result: ActivityResult, + activity: Activity, + callbacks: FilePicker.Callbacks + ) { + FilePicker.onPictureReturnedFromCamera(result, activity, callbacks) + } + + /** + * Attaches callback for file picker. + */ + fun handleActivityResultWithCallback( + activity: Activity, + handleActivityResult: HandleActivityResult + ) { + handleActivityResult.onHandleActivityResult(object : DefaultCallback() { + override fun onCanceled(source: FilePicker.ImageSource, type: Int) { + super.onCanceled(source, type) + defaultKvStore.remove(PLACE_OBJECT) + } + + override fun onImagePickerError( + e: Exception, source: FilePicker.ImageSource, + type: Int + ) { + showShortToast(activity, R.string.error_occurred_in_picking_images) + } + + override fun onImagesPicked( + imagesFiles: List, + source: FilePicker.ImageSource, type: Int + ) { + val intent = handleImagesPicked(activity, imagesFiles) + activity.startActivity(intent) + } + }) + } + + fun handleExternalImagesPicked( + activity: Activity, + data: Intent? + ): List { + return handleExternalImagesPicked(data, activity) + } + + /** + * Returns intent to be passed to upload activity Attaches place object for nearby uploads and + * location before image capture if in-app camera is used + */ + private fun handleImagesPicked( + context: Context, + imagesFiles: List + ): Intent { + val shareIntent = Intent(context, UploadActivity::class.java) + shareIntent.setAction(ACTION_INTERNAL_UPLOADS) + shareIntent + .putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, ArrayList(imagesFiles)) + val place = defaultKvStore.getJson(PLACE_OBJECT, Place::class.java) + + if (place != null) { + shareIntent.putExtra(PLACE_OBJECT, place) + } + + if (locationBeforeImageCapture != null) { + shareIntent.putExtra( + UploadActivity.LOCATION_BEFORE_IMAGE_CAPTURE, + locationBeforeImageCapture + ) + } + + shareIntent.putExtra( + UploadActivity.IN_APP_CAMERA_UPLOAD, + isInAppCameraUpload + ) + isInAppCameraUpload = false // reset the flag for next use + return shareIntent + } + + val pendingContributions: Unit + /** + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it + * populates the `pendingContributionList`. + */ + get() { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory = repository!!.fetchContributionsWithStates( + Arrays.asList( + Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, + Contribution.STATE_PAUSED + ) + ) + + val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig) + pendingContributionList = livePagedListBuilder.build() + } + + val failedContributions: Unit + /** + * Fetches the contributions with the state "FAILED" and populates the + * `failedContributionList`. + */ + get() { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory = repository!!.fetchContributionsWithStates( + listOf(Contribution.STATE_FAILED) + ) + + val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig) + failedContributionList = livePagedListBuilder.build() + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and + * then it populates the `failedAndPendingContributionList`. + */ + // void getFailedAndPendingContributions() { + // final PagedList.Config pagedListConfig = + // (new PagedList.Config.Builder()) + // .setPrefetchDistance(50) + // .setPageSize(10).build(); + // Factory factory; + // factory = repository.fetchContributionsWithStates( + // Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, + // Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); + // + // LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, + // pagedListConfig); + // failedAndPendingContributionList = livePagedListBuilder.build(); + // } + + companion object { + const val ACTION_INTERNAL_UPLOADS: String = "internalImageUploads" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java deleted file mode 100644 index 2e375145c..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java +++ /dev/null @@ -1,145 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.database.sqlite.SQLiteException; -import androidx.paging.DataSource; -import androidx.room.Dao; -import androidx.room.Delete; -import androidx.room.Insert; -import androidx.room.OnConflictStrategy; -import androidx.room.Query; -import androidx.room.Transaction; -import androidx.room.Update; -import io.reactivex.Completable; -import io.reactivex.Single; -import java.util.Calendar; -import java.util.List; -import timber.log.Timber; - -@Dao -public abstract class ContributionDao { - - @Query("SELECT * FROM contribution order by media_dateUploaded DESC") - abstract DataSource.Factory fetchContributions(); - - @Insert(onConflict = OnConflictStrategy.REPLACE) - public abstract void saveSynchronous(Contribution contribution); - - public Completable save(final Contribution contribution) { - return Completable - .fromAction(() -> { - contribution.setDateModified(Calendar.getInstance().getTime()); - if (contribution.getDateUploadStarted() == null) { - contribution.setDateUploadStarted(Calendar.getInstance().getTime()); - } - saveSynchronous(contribution); - }); - } - - @Transaction - public void deleteAndSaveContribution(final Contribution oldContribution, - final Contribution newContribution) { - deleteSynchronous(oldContribution); - saveSynchronous(newContribution); - } - - @Insert(onConflict = OnConflictStrategy.REPLACE) - public abstract Single> save(List contribution); - - @Delete - public abstract void deleteSynchronous(Contribution contribution); - - /** - * Deletes contributions with specific states from the database. - * - * @param states The states of the contributions to delete. - * @throws SQLiteException If an SQLite error occurs. - */ - @Query("DELETE FROM contribution WHERE state IN (:states)") - public abstract void deleteContributionsWithStatesSynchronous(List states) - throws SQLiteException; - - public Completable delete(final Contribution contribution) { - return Completable - .fromAction(() -> deleteSynchronous(contribution)); - } - - /** - * Deletes contributions with specific states from the database. - * - * @param states The states of the contributions to delete. - * @return A Completable indicating the result of the operation. - */ - public Completable deleteContributionsWithStates(List states) { - return Completable - .fromAction(() -> deleteContributionsWithStatesSynchronous(states)); - } - - @Query("SELECT * from contribution WHERE media_filename=:fileName") - public abstract List getContributionWithTitle(String fileName); - - @Query("SELECT * from contribution WHERE pageId=:pageId") - public abstract Contribution getContribution(String pageId); - - @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") - public abstract Single> getContribution(List states); - - /** - * Gets contributions with specific states in descending order by the date they were uploaded. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states. - */ - @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") - public abstract DataSource.Factory getContributions( - List states); - - /** - * Gets contributions with specific states in ascending order by the date the upload started. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states. - */ - @Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC") - public abstract DataSource.Factory getContributionsSortedByDateUploadStarted( - List states); - - @Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)") - public abstract Single getPendingUploads(int[] toUpdateStates); - - @Query("Delete FROM contribution") - public abstract void deleteAll() throws SQLiteException; - - @Update - public abstract void updateSynchronous(Contribution contribution); - - /** - * Updates the state of contributions with specific states. - * - * @param states The current states of the contributions to update. - * @param newState The new state to set. - */ - @Query("UPDATE contribution SET state = :newState WHERE state IN (:states)") - public abstract void updateContributionsState(List states, int newState); - - public Completable update(final Contribution contribution) { - return Completable - .fromAction(() -> { - contribution.setDateModified(Calendar.getInstance().getTime()); - updateSynchronous(contribution); - }); - } - - /** - * Updates the state of contributions with specific states asynchronously. - * - * @param states The current states of the contributions to update. - * @param newState The new state to set. - * @return A Completable indicating the result of the operation. - */ - public Completable updateContributionsWithStates(List states, int newState) { - return Completable - .fromAction(() -> { - updateContributionsState(states, newState); - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt new file mode 100644 index 000000000..50faa1340 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt @@ -0,0 +1,148 @@ +package fr.free.nrw.commons.contributions + +import android.database.sqlite.SQLiteException +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.reactivex.Completable +import io.reactivex.Single +import java.util.Calendar + +@Dao +abstract class ContributionDao { + @Query("SELECT * FROM contribution order by media_dateUploaded DESC") + abstract fun fetchContributions(): DataSource.Factory + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun saveSynchronous(contribution: Contribution) + + fun save(contribution: Contribution): Completable { + return Completable + .fromAction { + contribution.dateModified = Calendar.getInstance().time + if (contribution.dateUploadStarted == null) { + contribution.dateUploadStarted = Calendar.getInstance().time + } + saveSynchronous(contribution) + } + } + + @Transaction + open fun deleteAndSaveContribution( + oldContribution: Contribution, + newContribution: Contribution + ) { + deleteSynchronous(oldContribution) + saveSynchronous(newContribution) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun save(contribution: List): Single> + + @Delete + abstract fun deleteSynchronous(contribution: Contribution) + + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @throws SQLiteException If an SQLite error occurs. + */ + @Query("DELETE FROM contribution WHERE state IN (:states)") + @Throws(SQLiteException::class) + abstract fun deleteContributionsWithStatesSynchronous(states: List) + + fun delete(contribution: Contribution): Completable { + return Completable + .fromAction { deleteSynchronous(contribution) } + } + + /** + * Deletes contributions with specific states from the database. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsWithStates(states: List): Completable { + return Completable + .fromAction { deleteContributionsWithStatesSynchronous(states) } + } + + @Query("SELECT * from contribution WHERE media_filename=:fileName") + abstract fun getContributionWithTitle(fileName: String): List + + @Query("SELECT * from contribution WHERE pageId=:pageId") + abstract fun getContribution(pageId: String): Contribution + + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + abstract fun getContribution(states: List): Single> + + /** + * Gets contributions with specific states in descending order by the date they were uploaded. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC") + abstract fun getContributions( + states: List + ): DataSource.Factory + + /** + * Gets contributions with specific states in ascending order by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + @Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC") + abstract fun getContributionsSortedByDateUploadStarted( + states: List + ): DataSource.Factory + + @Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)") + abstract fun getPendingUploads(toUpdateStates: IntArray): Single + + @Query("Delete FROM contribution") + @Throws(SQLiteException::class) + abstract fun deleteAll() + + @Update + abstract fun updateSynchronous(contribution: Contribution) + + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + */ + @Query("UPDATE contribution SET state = :newState WHERE state IN (:states)") + abstract fun updateContributionsState(states: List, newState: Int) + + fun update(contribution: Contribution): Completable { + return Completable.fromAction { + contribution.dateModified = Calendar.getInstance().time + updateSynchronous(contribution) + } + } + + + + /** + * Updates the state of contributions with specific states asynchronously. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return Completable + .fromAction { + updateContributionsState(states, newState) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java deleted file mode 100644 index 568ac9a37..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java +++ /dev/null @@ -1,171 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.net.Uri; -import android.text.TextUtils; -import android.view.View; -import android.webkit.URLUtil; -import android.widget.ImageButton; -import android.widget.ProgressBar; -import android.widget.RelativeLayout; -import android.widget.TextView; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AlertDialog.Builder; -import androidx.recyclerview.widget.RecyclerView; -import com.facebook.drawee.view.SimpleDraweeView; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback; -import fr.free.nrw.commons.databinding.LayoutContributionBinding; -import fr.free.nrw.commons.media.MediaClient; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.io.File; - -public class ContributionViewHolder extends RecyclerView.ViewHolder { - - private final Callback callback; - - LayoutContributionBinding binding; - - private int position; - private Contribution contribution; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - private final MediaClient mediaClient; - private boolean isWikipediaButtonDisplayed; - private AlertDialog pausingPopUp; - private View parent; - private ImageRequest imageRequest; - - ContributionViewHolder(final View parent, final Callback callback, - final MediaClient mediaClient) { - super(parent); - this.parent = parent; - this.mediaClient = mediaClient; - this.callback = callback; - - binding = LayoutContributionBinding.bind(parent); - - binding.contributionImage.setOnClickListener(v -> imageClicked()); - binding.wikipediaButton.setOnClickListener(v -> wikipediaButtonClicked()); - - /* Set a dialog indicating that the upload is being paused. This is needed because pausing - an upload might take a dozen seconds. */ - AlertDialog.Builder builder = new Builder(parent.getContext()); - builder.setCancelable(false); - builder.setView(R.layout.progress_dialog); - pausingPopUp = builder.create(); - } - - public void init(final int position, final Contribution contribution) { - - //handling crashes when the contribution is null. - if (null == contribution) { - return; - } - - this.contribution = contribution; - this.position = position; - binding.contributionTitle.setText(contribution.getMedia().getMostRelevantCaption()); - binding.authorView.setText(contribution.getMedia().getAuthor()); - - //Removes flicker of loading image. - binding.contributionImage.getHierarchy().setFadeDuration(0); - - binding.contributionImage.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); - binding.contributionImage.getHierarchy().setFailureImage(R.drawable.image_placeholder); - - final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(), - contribution.getLocalUri()); - if (!TextUtils.isEmpty(imageSource)) { - if (URLUtil.isHttpsUrl(imageSource)) { - imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) - .setProgressiveRenderingEnabled(true) - .build(); - } else if (URLUtil.isFileUrl(imageSource)) { - imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)); - } else if (imageSource != null) { - final File file = new File(imageSource); - imageRequest = ImageRequest.fromFile(file); - } - - if (imageRequest != null) { - binding.contributionImage.setImageRequest(imageRequest); - } - } - - binding.contributionSequenceNumber.setText(String.valueOf(position + 1)); - binding.contributionSequenceNumber.setVisibility(View.VISIBLE); - binding.wikipediaButton.setVisibility(View.GONE); - binding.contributionState.setVisibility(View.GONE); - binding.contributionProgress.setVisibility(View.GONE); - binding.imageOptions.setVisibility(View.GONE); - binding.contributionState.setText(""); - checkIfMediaExistsOnWikipediaPage(contribution); - - } - - /** - * Checks if a media exists on the corresponding Wikipedia article Currently the check is made - * for the device's current language Wikipedia - * - * @param contribution - */ - private void checkIfMediaExistsOnWikipediaPage(final Contribution contribution) { - if (contribution.getWikidataPlace() == null - || contribution.getWikidataPlace().getWikipediaArticle() == null) { - return; - } - final String wikipediaArticle = contribution.getWikidataPlace().getWikipediaPageTitle(); - compositeDisposable.add(mediaClient.doesPageContainMedia(wikipediaArticle) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(mediaExists -> { - displayWikipediaButton(mediaExists); - })); - } - - /** - * Handle action buttons visibility if the corresponding wikipedia page doesn't contain any - * media. This method needs to control the state of just the scenario where media does not - * exists as other scenarios are already handled in the init method. - * - * @param mediaExists - */ - private void displayWikipediaButton(Boolean mediaExists) { - if (!mediaExists) { - binding.wikipediaButton.setVisibility(View.VISIBLE); - isWikipediaButtonDisplayed = true; - binding.imageOptions.setVisibility(View.VISIBLE); - } - } - - /** - * Returns the image source for the image view, first preference is given to thumbUrl if that is - * null, moves to local uri and if both are null return null - * - * @param thumbUrl - * @param localUri - * @return - */ - @Nullable - private String chooseImageSource(final String thumbUrl, final Uri localUri) { - return !TextUtils.isEmpty(thumbUrl) ? thumbUrl : - localUri != null ? localUri.toString() : - null; - } - - public void imageClicked() { - callback.openMediaDetail(position, isWikipediaButtonDisplayed); - } - - public void wikipediaButtonClicked() { - callback.addImageToWikipedia(contribution); - } - - public ImageRequest getImageRequest() { - return imageRequest; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt new file mode 100644 index 000000000..d1dbf4509 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt @@ -0,0 +1,152 @@ +package fr.free.nrw.commons.contributions + +import android.net.Uri +import android.text.TextUtils +import android.view.View +import android.webkit.URLUtil +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.RecyclerView +import com.facebook.imagepipeline.request.ImageRequest +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.R +import fr.free.nrw.commons.databinding.LayoutContributionBinding +import fr.free.nrw.commons.media.MediaClient +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import java.io.File + +class ContributionViewHolder internal constructor( + private val parent: View, private val callback: ContributionsListAdapter.Callback, + private val mediaClient: MediaClient +) : RecyclerView.ViewHolder(parent) { + var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent) + + private var position = 0 + private var contribution: Contribution? = null + private val compositeDisposable = CompositeDisposable() + private var isWikipediaButtonDisplayed = false + private val pausingPopUp: AlertDialog + var imageRequest: ImageRequest? = null + private set + + init { + binding.contributionImage.setOnClickListener { v: View? -> imageClicked() } + binding.wikipediaButton.setOnClickListener { v: View? -> wikipediaButtonClicked() } + + /* Set a dialog indicating that the upload is being paused. This is needed because pausing +an upload might take a dozen seconds. */ + val builder = AlertDialog.Builder( + parent.context + ) + builder.setCancelable(false) + builder.setView(R.layout.progress_dialog) + pausingPopUp = builder.create() + } + + fun init(position: Int, contribution: Contribution?) { + //handling crashes when the contribution is null. + + if (null == contribution) { + return + } + + this.contribution = contribution + this.position = position + binding.contributionTitle.text = contribution.media.mostRelevantCaption + binding.authorView.text = contribution.media.author + + //Removes flicker of loading image. + binding.contributionImage.hierarchy.fadeDuration = 0 + + binding.contributionImage.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) + binding.contributionImage.hierarchy.setFailureImage(R.drawable.image_placeholder) + + val imageSource = chooseImageSource( + contribution.media.thumbUrl, + contribution.localUri + ) + if (!TextUtils.isEmpty(imageSource)) { + if (URLUtil.isHttpsUrl(imageSource)) { + imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource)) + .setProgressiveRenderingEnabled(true) + .build() + } else if (URLUtil.isFileUrl(imageSource)) { + imageRequest = ImageRequest.fromUri(Uri.parse(imageSource)) + } else if (imageSource != null) { + val file = File(imageSource) + imageRequest = ImageRequest.fromFile(file) + } + + if (imageRequest != null) { + binding.contributionImage.setImageRequest(imageRequest) + } + } + + binding.contributionSequenceNumber.text = (position + 1).toString() + binding.contributionSequenceNumber.visibility = View.VISIBLE + binding.wikipediaButton.visibility = View.GONE + binding.contributionState.visibility = View.GONE + binding.contributionProgress.visibility = View.GONE + binding.imageOptions.visibility = View.GONE + binding.contributionState.text = "" + checkIfMediaExistsOnWikipediaPage(contribution) + } + + /** + * Checks if a media exists on the corresponding Wikipedia article Currently the check is made + * for the device's current language Wikipedia + * + * @param contribution + */ + private fun checkIfMediaExistsOnWikipediaPage(contribution: Contribution) { + if (contribution.wikidataPlace == null + || contribution.wikidataPlace!!.wikipediaArticle == null + ) { + return + } + val wikipediaArticle = contribution.wikidataPlace!!.getWikipediaPageTitle() + compositeDisposable.add( + mediaClient.doesPageContainMedia(wikipediaArticle) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { mediaExists: Boolean -> + displayWikipediaButton(mediaExists) + }) + } + + /** + * Handle action buttons visibility if the corresponding wikipedia page doesn't contain any + * media. This method needs to control the state of just the scenario where media does not + * exists as other scenarios are already handled in the init method. + * + * @param mediaExists + */ + private fun displayWikipediaButton(mediaExists: Boolean) { + if (!mediaExists) { + binding.wikipediaButton.visibility = View.VISIBLE + isWikipediaButtonDisplayed = true + binding.imageOptions.visibility = View.VISIBLE + } + } + + /** + * Returns the image source for the image view, first preference is given to thumbUrl if that is + * null, moves to local uri and if both are null return null + * + * @param thumbUrl + * @param localUri + * @return + */ + private fun chooseImageSource(thumbUrl: String?, localUri: Uri?): String? { + return if (!TextUtils.isEmpty(thumbUrl)) thumbUrl else localUri?.toString() + } + + fun imageClicked() { + callback.openMediaDetail(position, isWikipediaButtonDisplayed) + } + + fun wikipediaButtonClicked() { + callback.addImageToWikipedia(contribution) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java deleted file mode 100644 index 439780332..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.content.Context; -import fr.free.nrw.commons.BasePresenter; - -/** - * The contract for Contributions View & Presenter - */ -public class ContributionsContract { - - public interface View { - - void showMessage(String localizedMessage); - - Context getContext(); - } - - public interface UserActionListener extends BasePresenter { - - Contribution getContributionsWithTitle(String uri); - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt new file mode 100644 index 000000000..269536428 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import fr.free.nrw.commons.BasePresenter + +/** + * The contract for Contributions View & Presenter + */ +interface ContributionsContract { + + interface View { + fun showMessage(localizedMessage: String) + fun getContext(): Context + } + + interface UserActionListener : BasePresenter { + fun getContributionsWithTitle(uri: String): Contribution + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java deleted file mode 100644 index ca9677691..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java +++ /dev/null @@ -1,940 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static android.content.Context.SENSOR_SERVICE; -import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED; -import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED; -import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL; -import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME; -import static fr.free.nrw.commons.utils.LengthUtils.computeBearing; -import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; - -import android.Manifest; -import android.Manifest.permission; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.Intent; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; -import androidx.fragment.app.FragmentTransaction; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.databinding.FragmentContributionsBinding; -import fr.free.nrw.commons.notification.models.Notification; -import fr.free.nrw.commons.notification.NotificationController; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.upload.UploadProgressActivity; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Map; -import javax.inject.Inject; -import javax.inject.Named; -import androidx.work.WorkManager; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.campaigns.models.Campaign; -import fr.free.nrw.commons.campaigns.CampaignView; -import fr.free.nrw.commons.campaigns.CampaignsPresenter; -import fr.free.nrw.commons.campaigns.ICampaignsView; -import fr.free.nrw.commons.contributions.ContributionsListFragment.Callback; -import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.location.LocationUpdateListener; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.nearby.NearbyController; -import fr.free.nrw.commons.nearby.NearbyNotificationCardView; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.notification.NotificationActivity; -import fr.free.nrw.commons.upload.worker.UploadWorker; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.NetworkUtils; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import timber.log.Timber; - -public class ContributionsFragment - extends CommonsDaggerSupportFragment - implements - OnBackStackChangedListener, - LocationUpdateListener, - MediaDetailProvider, - SensorEventListener, - ICampaignsView, ContributionsContract.View, Callback { - - @Inject - @Named("default_preferences") - JsonKvStore store; - @Inject - NearbyController nearbyController; - @Inject - OkHttpJsonApiClient okHttpJsonApiClient; - @Inject - CampaignsPresenter presenter; - @Inject - LocationServiceManager locationManager; - @Inject - NotificationController notificationController; - @Inject - ContributionController contributionController; - - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - private ContributionsListFragment contributionsListFragment; - private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag"; - private MediaDetailPagerFragment mediaDetailPagerFragment; - static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag"; - private static final int MAX_RETRIES = 10; - - public FragmentContributionsBinding binding; - - @Inject - ContributionsPresenter contributionsPresenter; - - @Inject - SessionManager sessionManager; - - private LatLng currentLatLng; - - private boolean isFragmentAttachedBefore = false; - private View checkBoxView; - private CheckBox checkBox; - - public TextView notificationCount; - - public TextView pendingUploadsCountTextView; - - public TextView uploadsErrorTextView; - - public ImageView pendingUploadsImageView; - - private Campaign wlmCampaign; - - String userName; - private boolean isUserProfile; - - private SensorManager mSensorManager; - private Sensor mLight; - private float direction; - private ActivityResultLauncher nearbyLocationPermissionLauncher = registerForActivityResult( - new ActivityResultContracts.RequestMultiplePermissions(), - new ActivityResultCallback>() { - @Override - public void onActivityResult(Map result) { - boolean areAllGranted = true; - for (final boolean b : result.values()) { - areAllGranted = areAllGranted && b; - } - - if (areAllGranted) { - onLocationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale( - Manifest.permission.ACCESS_FINE_LOCATION) - && store.getBoolean("displayLocationPermissionForCardView", true) - && !store.getBoolean("doNotAskForLocationPermission", false) - && (((MainActivity) getActivity()).activeFragment - == ActiveFragment.CONTRIBUTIONS)) { - binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION; - } else { - displayYouWontSeeNearbyMessage(); - } - } - } - }); - - @NonNull - public static ContributionsFragment newInstance() { - ContributionsFragment fragment = new ContributionsFragment(); - fragment.setRetainInstance(true); - return fragment; - } - - private boolean shouldShowMediaDetailsFragment; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null && getArguments().getString(KEY_USERNAME) != null) { - userName = getArguments().getString(KEY_USERNAME); - isUserProfile = true; - } - mSensorManager = (SensorManager) getActivity().getSystemService(SENSOR_SERVICE); - mLight = mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION); - } - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - - binding = FragmentContributionsBinding.inflate(inflater, container, false); - - initWLMCampaign(); - presenter.onAttachView(this); - contributionsPresenter.onAttachView(this); - binding.campaignsView.setVisibility(View.GONE); - checkBoxView = View.inflate(getActivity(), R.layout.nearby_permission_dialog, null); - checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again); - checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - // Do not ask for permission on activity start again - store.putBoolean("displayLocationPermissionForCardView", false); - } - }); - - if (savedInstanceState != null) { - mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager() - .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG); - contributionsListFragment = (ContributionsListFragment) getChildFragmentManager() - .findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG); - shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible"); - } - - initFragments(); - if (!isUserProfile) { - upDateUploadCount(); - } - if (shouldShowMediaDetailsFragment) { - showMediaDetailPagerFragment(); - } else { - if (mediaDetailPagerFragment != null) { - removeFragment(mediaDetailPagerFragment); - } - showContributionsListFragment(); - } - - if (!ConfigUtils.isBetaFlavour() && sessionManager.isUserLoggedIn() - && sessionManager.getCurrentAccount() != null && !isUserProfile) { - setUploadCount(); - } - setHasOptionsMenu(true); - return binding.getRoot(); - } - - /** - * Initialise the campaign object for WML - */ - private void initWLMCampaign() { - wlmCampaign = new Campaign(getString(R.string.wlm_campaign_title), - getString(R.string.wlm_campaign_description), Utils.getWLMStartDate().toString(), - Utils.getWLMEndDate().toString(), WLM_URL, true); - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - - // Removing contributions menu items for ProfileActivity - if (getActivity() instanceof ProfileActivity) { - return; - } - - inflater.inflate(R.menu.contribution_activity_notification_menu, menu); - - MenuItem notificationsMenuItem = menu.findItem(R.id.notifications); - final View notification = notificationsMenuItem.getActionView(); - notificationCount = notification.findViewById(R.id.notification_count_badge); - MenuItem uploadMenuItem = menu.findItem(R.id.upload_tab); - final View uploadMenuItemActionView = uploadMenuItem.getActionView(); - pendingUploadsCountTextView = uploadMenuItemActionView.findViewById( - R.id.pending_uploads_count_badge); - uploadsErrorTextView = uploadMenuItemActionView.findViewById( - R.id.uploads_error_count_badge); - pendingUploadsImageView = uploadMenuItemActionView.findViewById( - R.id.pending_uploads_image_view); - if (pendingUploadsImageView != null) { - pendingUploadsImageView.setOnClickListener(view -> { - startActivity(new Intent(getContext(), UploadProgressActivity.class)); - }); - } - if (pendingUploadsCountTextView != null) { - pendingUploadsCountTextView.setOnClickListener(view -> { - startActivity(new Intent(getContext(), UploadProgressActivity.class)); - }); - } - if (uploadsErrorTextView != null) { - uploadsErrorTextView.setOnClickListener(view -> { - startActivity(new Intent(getContext(), UploadProgressActivity.class)); - }); - } - notification.setOnClickListener(view -> { - NotificationActivity.Companion.startYourself(getContext(), "unread"); - }); - } - - @SuppressLint("CheckResult") - public void setNotificationCount() { - compositeDisposable.add(notificationController.getNotifications(false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::initNotificationViews, - throwable -> Timber.e(throwable, "Error occurred while loading notifications"))); - } - - /** - * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - * Sets the visibility of the upload icon based on the number of failed and pending - * contributions. - */ -// public void setUploadIconVisibility() { -// contributionController.getFailedAndPendingContributions(); -// contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), -// list -> { -// updateUploadIcon(list.size()); -// }); -// } - - /** - * Sets the count for the upload icon based on the number of pending and failed contributions. - */ - public void setUploadIconCount() { - contributionController.getPendingContributions(); - contributionController.pendingContributionList.observe(getViewLifecycleOwner(), - list -> { - updatePendingIcon(list.size()); - }); - contributionController.getFailedContributions(); - contributionController.failedContributionList.observe(getViewLifecycleOwner(), list -> { - updateErrorIcon(list.size()); - }); - } - - public void scrollToTop() { - if (contributionsListFragment != null) { - contributionsListFragment.scrollToTop(); - } - } - - private void initNotificationViews(List notificationList) { - Timber.d("Number of notifications is %d", notificationList.size()); - if (notificationList.isEmpty()) { - notificationCount.setVisibility(View.GONE); - } else { - notificationCount.setVisibility(View.VISIBLE); - notificationCount.setText(String.valueOf(notificationList.size())); - } - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - /* - - There are some operations we need auth, so we need to make sure isAuthCookieAcquired. - - And since we use same retained fragment doesn't want to make all network operations - all over again on same fragment attached to recreated activity, we do this network - operations on first time fragment attached to an activity. Then they will be retained - until fragment life time ends. - */ - if (!isFragmentAttachedBefore && getActivity() != null) { - isFragmentAttachedBefore = true; - } - } - - /** - * Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates - * new one if null. - */ - private void showContributionsListFragment() { - // show nearby card view on contributions list is visible - if (binding.cardViewNearby != null && !isUserProfile) { - if (store.getBoolean("displayNearbyCardView", true)) { - if (binding.cardViewNearby.cardViewVisibilityState - == NearbyNotificationCardView.CardViewVisibilityState.READY) { - binding.cardViewNearby.setVisibility(View.VISIBLE); - } - } else { - binding.cardViewNearby.setVisibility(View.GONE); - } - } - showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, - mediaDetailPagerFragment); - } - - private void showMediaDetailPagerFragment() { - // hide nearby card view on media detail is visible - setupViewForMediaDetails(); - showFragment(mediaDetailPagerFragment, MEDIA_DETAIL_PAGER_FRAGMENT_TAG, - contributionsListFragment); - } - - private void setupViewForMediaDetails() { - if (binding != null) { - binding.campaignsView.setVisibility(View.GONE); - } - } - - @Override - public void onBackStackChanged() { - fetchCampaigns(); - } - - private void initFragments() { - if (null == contributionsListFragment) { - contributionsListFragment = new ContributionsListFragment(); - Bundle contributionsListBundle = new Bundle(); - contributionsListBundle.putString(KEY_USERNAME, userName); - contributionsListFragment.setArguments(contributionsListBundle); - } - - if (shouldShowMediaDetailsFragment) { - showMediaDetailPagerFragment(); - } else { - showContributionsListFragment(); - } - - showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, - mediaDetailPagerFragment); - } - - /** - * Replaces the root frame layout with the given fragment - * - * @param fragment - * @param tag - * @param otherFragment - */ - private void showFragment(Fragment fragment, String tag, Fragment otherFragment) { - FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - if (fragment.isAdded() && otherFragment != null) { - transaction.hide(otherFragment); - transaction.show(fragment); - transaction.addToBackStack(tag); - transaction.commit(); - getChildFragmentManager().executePendingTransactions(); - } else if (fragment.isAdded() && otherFragment == null) { - transaction.show(fragment); - transaction.addToBackStack(tag); - transaction.commit(); - getChildFragmentManager().executePendingTransactions(); - } else if (!fragment.isAdded() && otherFragment != null) { - transaction.hide(otherFragment); - transaction.add(R.id.root_frame, fragment, tag); - transaction.addToBackStack(tag); - transaction.commit(); - getChildFragmentManager().executePendingTransactions(); - } else if (!fragment.isAdded()) { - transaction.replace(R.id.root_frame, fragment, tag); - transaction.addToBackStack(tag); - transaction.commit(); - getChildFragmentManager().executePendingTransactions(); - } - } - - public void removeFragment(Fragment fragment) { - getChildFragmentManager() - .beginTransaction() - .remove(fragment) - .commit(); - getChildFragmentManager().executePendingTransactions(); - } - - @SuppressWarnings("ConstantConditions") - private void setUploadCount() { - compositeDisposable.add(okHttpJsonApiClient - .getUploadCount(((MainActivity) getActivity()).sessionManager.getCurrentAccount().name) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::displayUploadCount, - t -> Timber.e(t, "Fetching upload count failed") - )); - } - - private void displayUploadCount(Integer uploadCount) { - if (getActivity().isFinishing() - || getResources() == null) { - return; - } - - ((MainActivity) getActivity()).setNumOfUploads(uploadCount); - - } - - @Override - public void onPause() { - super.onPause(); - locationManager.removeLocationListener(this); - locationManager.unregisterLocationManager(); - mSensorManager.unregisterListener(this); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - } - - @Override - public void onResume() { - super.onResume(); - contributionsPresenter.onAttachView(this); - locationManager.addLocationListener(this); - - if (binding == null) { - return; - } - - binding.cardViewNearby.permissionRequestButton.setOnClickListener(v -> { - showNearbyCardPermissionRationale(); - }); - - // Notification cards should only be seen on contributions list, not in media details - if (mediaDetailPagerFragment == null && !isUserProfile) { - if (store.getBoolean("displayNearbyCardView", true)) { - checkPermissionsAndShowNearbyCardView(); - - // Calling nearby card to keep showing it even when user clicks on it and comes back - try { - updateClosestNearbyCardViewInfo(); - } catch (Exception e) { - Timber.e(e); - } - if (binding.cardViewNearby.cardViewVisibilityState - == NearbyNotificationCardView.CardViewVisibilityState.READY) { - binding.cardViewNearby.setVisibility(View.VISIBLE); - } - - } else { - // Hide nearby notification card view if related shared preferences is false - binding.cardViewNearby.setVisibility(View.GONE); - } - - // Notification Count and Campaigns should not be set, if it is used in User Profile - if (!isUserProfile) { - setNotificationCount(); - fetchCampaigns(); - // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - // setUploadIconVisibility(); - setUploadIconCount(); - } - } - mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI); - } - - private void checkPermissionsAndShowNearbyCardView() { - if (PermissionUtils.hasPermission(getActivity(), - new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) { - onLocationPermissionGranted(); - } else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) - && store.getBoolean("displayLocationPermissionForCardView", true) - && !store.getBoolean("doNotAskForLocationPermission", false) - && (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) { - binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION; - showNearbyCardPermissionRationale(); - } - } - - private void requestLocationPermission() { - nearbyLocationPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION}); - } - - private void onLocationPermissionGranted() { - binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED; - locationManager.registerLocationManager(); - } - - private void showNearbyCardPermissionRationale() { - DialogUtil.showAlertDialog(getActivity(), - getString(R.string.nearby_card_permission_title), - getString(R.string.nearby_card_permission_explanation), - this::requestLocationPermission, - this::displayYouWontSeeNearbyMessage, - checkBoxView - ); - } - - private void displayYouWontSeeNearbyMessage() { - ViewUtil.showLongToast(getActivity(), - getResources().getString(R.string.unable_to_display_nearest_place)); - // Set to true as the user doesn't want the app to ask for location permission anymore - store.putBoolean("doNotAskForLocationPermission", true); - } - - - private void updateClosestNearbyCardViewInfo() { - currentLatLng = locationManager.getLastLocation(); - compositeDisposable.add(Observable.fromCallable(() -> nearbyController - .loadAttractionsFromLocation(currentLatLng, currentLatLng, true, - false)) // thanks to boolean, it will only return closest result - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateNearbyNotification, - throwable -> { - Timber.d(throwable); - updateNearbyNotification(null); - })); - } - - private void updateNearbyNotification( - @Nullable NearbyController.NearbyPlacesInfo nearbyPlacesInfo) { - if (nearbyPlacesInfo != null && nearbyPlacesInfo.placeList != null - && nearbyPlacesInfo.placeList.size() > 0) { - Place closestNearbyPlace = null; - // Find the first nearby place that has no image and exists - for (Place place : nearbyPlacesInfo.placeList) { - if (place.pic.equals("") && place.exists) { - closestNearbyPlace = place; - break; - } - } - - if (closestNearbyPlace == null) { - binding.cardViewNearby.setVisibility(View.GONE); - } else { - String distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location); - closestNearbyPlace.setDistance(distance); - direction = (float) computeBearing(currentLatLng, closestNearbyPlace.location); - binding.cardViewNearby.updateContent(closestNearbyPlace); - } - } else { - // Means that no close nearby place is found - binding.cardViewNearby.setVisibility(View.GONE); - } - - // Prevent Nearby banner from appearing in Media Details, fixing bug https://github.com/commons-app/apps-android-commons/issues/4731 - if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) { - binding.cardViewNearby.setVisibility(View.GONE); - } - } - - @Override - public void onDestroy() { - try { - compositeDisposable.clear(); - getChildFragmentManager().removeOnBackStackChangedListener(this); - locationManager.unregisterLocationManager(); - locationManager.removeLocationListener(this); - super.onDestroy(); - } catch (IllegalArgumentException | IllegalStateException exception) { - Timber.e(exception); - } - } - - @Override - public void onLocationChangedSignificantly(LatLng latLng) { - // Will be called if location changed more than 1000 meter - updateClosestNearbyCardViewInfo(); - } - - @Override - public void onLocationChangedSlightly(LatLng latLng) { - /* Update closest nearby notification card onLocationChangedSlightly - */ - try { - updateClosestNearbyCardViewInfo(); - } catch (Exception e) { - Timber.e(e); - } - } - - @Override - public void onLocationChangedMedium(LatLng latLng) { - // Update closest nearby card view if location changed more than 500 meters - updateClosestNearbyCardViewInfo(); - } - - @Override - public void onViewCreated(@NonNull View view, - @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - } - - /** - * As the home screen has limited space, we have choosen to show either campaigns or WLM card. - * The WLM Card gets the priority over monuments, so if the WLM is going on we show that instead - * of campaigns on the campaigns card - */ - private void fetchCampaigns() { - if (Utils.isMonumentsEnabled(new Date())) { - if (binding != null) { - binding.campaignsView.setCampaign(wlmCampaign); - binding.campaignsView.setVisibility(View.VISIBLE); - } - } else if (store.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) { - presenter.getCampaigns(); - } else { - if (binding != null) { - binding.campaignsView.setVisibility(View.GONE); - } - } - } - - @Override - public void showMessage(String message) { - Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show(); - } - - @Override - public void showCampaigns(Campaign campaign) { - if (campaign != null && !isUserProfile) { - if (binding != null) { - binding.campaignsView.setCampaign(campaign); - } - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - presenter.onDetachView(); - } - - @Override - public void notifyDataSetChanged() { - if (mediaDetailPagerFragment != null) { - mediaDetailPagerFragment.notifyDataSetChanged(); - } - } - - /** - * Notify the viewpager that number of items have changed. - */ - @Override - public void viewPagerNotifyDataSetChanged() { - if (mediaDetailPagerFragment != null) { - mediaDetailPagerFragment.notifyDataSetChanged(); - } - } - - /** - * Updates the visibility and text of the pending uploads count TextView based on the given - * count. - * - * @param pendingCount The number of pending uploads. - */ - public void updatePendingIcon(int pendingCount) { - if (pendingUploadsCountTextView != null) { - if (pendingCount != 0) { - pendingUploadsCountTextView.setVisibility(View.VISIBLE); - pendingUploadsCountTextView.setText(String.valueOf(pendingCount)); - } else { - pendingUploadsCountTextView.setVisibility(View.INVISIBLE); - } - } - } - - /** - * Updates the visibility and text of the error uploads TextView based on the given count. - * - * @param errorCount The number of error uploads. - */ - public void updateErrorIcon(int errorCount) { - if (uploadsErrorTextView != null) { - if (errorCount != 0) { - uploadsErrorTextView.setVisibility(View.VISIBLE); - uploadsErrorTextView.setText(String.valueOf(errorCount)); - } else { - uploadsErrorTextView.setVisibility(View.GONE); - } - } - } - - /** - * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - * @param count The number of pending uploads. - */ -// public void updateUploadIcon(int count) { -// if (pendingUploadsImageView != null) { -// if (count != 0) { -// pendingUploadsImageView.setVisibility(View.VISIBLE); -// } else { -// pendingUploadsImageView.setVisibility(View.GONE); -// } -// } -// } - - /** - * Replace whatever is in the current contributionsFragmentContainer view with - * mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects - * a contribution. - */ - @Override - public void showDetail(int position, boolean isWikipediaButtonDisplayed) { - if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) { - mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true); - if (isUserProfile) { - ((ProfileActivity) getActivity()).setScroll(false); - } - showMediaDetailPagerFragment(); - } - mediaDetailPagerFragment.showImage(position, isWikipediaButtonDisplayed); - } - - @Override - public Media getMediaAtPosition(int i) { - return contributionsListFragment.getMediaAtPosition(i); - } - - @Override - public int getTotalMediaCount() { - return contributionsListFragment.getTotalMediaCount(); - } - - @Override - public Integer getContributionStateAt(int position) { - return contributionsListFragment.getContributionStateAt(position); - } - - public boolean backButtonClicked() { - if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) { - if (store.getBoolean("displayNearbyCardView", true) && !isUserProfile) { - if (binding.cardViewNearby.cardViewVisibilityState - == NearbyNotificationCardView.CardViewVisibilityState.READY) { - binding.cardViewNearby.setVisibility(View.VISIBLE); - } - } else { - binding.cardViewNearby.setVisibility(View.GONE); - } - removeFragment(mediaDetailPagerFragment); - showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG, - mediaDetailPagerFragment); - if (isUserProfile) { - // Fragment is associated with ProfileActivity - // Enable ParentViewPager Scroll - ((ProfileActivity) getActivity()).setScroll(true); - } else { - fetchCampaigns(); - } - if (getActivity() instanceof MainActivity) { - // Fragment is associated with MainActivity - ((BaseActivity) getActivity()).getSupportActionBar() - .setDisplayHomeAsUpEnabled(false); - ((MainActivity) getActivity()).showTabs(); - } - return true; - } - return false; - } - - // Getter for mediaDetailPagerFragment - public MediaDetailPagerFragment getMediaDetailPagerFragment() { - return mediaDetailPagerFragment; - } - - - /** - * this function updates the number of contributions - */ - void upDateUploadCount() { - WorkManager.getInstance(getContext()) - .getWorkInfosForUniqueWorkLiveData(UploadWorker.class.getSimpleName()).observe( - getViewLifecycleOwner(), workInfos -> { - if (workInfos.size() > 0) { - setUploadCount(); - } - }); - } - - - /** - * Restarts the upload process for a contribution - * - * @param contribution - */ - public void restartUpload(Contribution contribution) { - contribution.setDateUploadStarted(Calendar.getInstance().getTime()); - if (contribution.getState() == Contribution.STATE_FAILED) { - if (contribution.getErrorInfo() == null) { - contribution.setChunkInfo(null); - contribution.setTransferred(0); - } - contributionsPresenter.checkDuplicateImageAndRestartContribution(contribution); - } else { - contribution.setState(Contribution.STATE_QUEUED); - contributionsPresenter.saveContribution(contribution); - Timber.d("Restarting for %s", contribution.toString()); - } - } - - /** - * Retry upload when it is failed - * - * @param contribution contribution to be retried - */ - public void retryUpload(Contribution contribution) { - if (NetworkUtils.isInternetConnectionEstablished(getContext())) { - if (contribution.getState() == STATE_PAUSED) { - restartUpload(contribution); - } else if (contribution.getState() == STATE_FAILED) { - int retries = contribution.getRetries(); - // TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562 - /* Limit the number of retries for a failed upload - to handle cases like invalid filename as such uploads - will never be successful */ - if (retries < MAX_RETRIES) { - contribution.setRetries(retries + 1); - Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(), - retries + 1); - restartUpload(contribution); - } else { - // TODO: Show the exact reason for failure - Toast.makeText(getContext(), - R.string.retry_limit_reached, Toast.LENGTH_SHORT).show(); - } - } else { - Timber.d("Skipping re-upload for non-failed %s", contribution.toString()); - } - } else { - ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection); - } - - } - - /** - * Reload media detail fragment once media is nominated - * - * @param index item position that has been nominated - */ - @Override - public void refreshNominatedMedia(int index) { - if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) { - removeFragment(mediaDetailPagerFragment); - mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true); - mediaDetailPagerFragment.showImage(index); - showMediaDetailPagerFragment(); - } - } - - /** - * When the device rotates, rotate the Nearby banner's compass arrow in tandem. - */ - @Override - public void onSensorChanged(SensorEvent event) { - float rotateDegree = Math.round(event.values[0]); - binding.cardViewNearby.rotateCompass(rotateDegree, direction); - } - - @Override - public void onAccuracyChanged(Sensor sensor, int accuracy) { - // Nothing to do. - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt new file mode 100644 index 000000000..0b7736bab --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt @@ -0,0 +1,998 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.CompoundButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import androidx.work.WorkInfo +import androidx.work.WorkManager +import fr.free.nrw.commons.MapController.NearbyPlacesInfo +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.campaigns.CampaignView +import fr.free.nrw.commons.campaigns.CampaignsPresenter +import fr.free.nrw.commons.campaigns.ICampaignsView +import fr.free.nrw.commons.campaigns.models.Campaign +import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment +import fr.free.nrw.commons.databinding.FragmentContributionsBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LatLng +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.location.LocationUpdateListener +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.nearby.NearbyController +import fr.free.nrw.commons.nearby.NearbyNotificationCardView +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +import fr.free.nrw.commons.notification.NotificationController +import fr.free.nrw.commons.notification.models.Notification +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.UploadWorker +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.LengthUtils.computeBearing +import fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween +import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished +import fr.free.nrw.commons.utils.PermissionUtils.hasPermission +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import java.util.Date +import javax.inject.Inject +import javax.inject.Named + +class ContributionsFragment + + : CommonsDaggerSupportFragment(), FragmentManager.OnBackStackChangedListener, + LocationUpdateListener, MediaDetailProvider, SensorEventListener, ICampaignsView, + ContributionsContract.View, + ContributionsListFragment.Callback { + @JvmField + @Inject + @Named("default_preferences") + var store: JsonKvStore? = null + + @JvmField + @Inject + var nearbyController: NearbyController? = null + + @JvmField + @Inject + var okHttpJsonApiClient: OkHttpJsonApiClient? = null + + @JvmField + @Inject + var presenter: CampaignsPresenter? = null + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var contributionController: ContributionController? = null + + override var compositeDisposable: CompositeDisposable = CompositeDisposable() + + private var contributionsListFragment: ContributionsListFragment? = null + + // Getter for mediaDetailPagerFragment + var mediaDetailPagerFragment: MediaDetailPagerFragment? = null + private set + var binding: FragmentContributionsBinding? = null + + @JvmField + @Inject + var contributionsPresenter: ContributionsPresenter? = null + + @JvmField + @Inject + var sessionManager: SessionManager? = null + + private var currentLatLng: LatLng? = null + + private var isFragmentAttachedBefore = false + private var checkBoxView: View? = null + private var checkBox: CheckBox? = null + + var notificationCount: TextView? = null + + var pendingUploadsCountTextView: TextView? = null + + var uploadsErrorTextView: TextView? = null + + var pendingUploadsImageView: ImageView? = null + + private var wlmCampaign: Campaign? = null + + var userName: String? = null + private var isUserProfile = false + + private var mSensorManager: SensorManager? = null + private var mLight: Sensor? = null + private var direction = 0f + private val nearbyLocationPermissionLauncher = + registerForActivityResult, Map>( + ActivityResultContracts.RequestMultiplePermissions(), + object : ActivityResultCallback> { + override fun onActivityResult(result: Map) { + var areAllGranted = true + for (b in result.values) { + areAllGranted = areAllGranted && b + } + + if (areAllGranted) { + onLocationPermissionGranted() + } else { + if (shouldShowRequestPermissionRationale( + permission.ACCESS_FINE_LOCATION + ) + && store!!.getBoolean("displayLocationPermissionForCardView", true) + && !store!!.getBoolean("doNotAskForLocationPermission", false) + && ((activity as MainActivity).activeFragment + == ActiveFragment.CONTRIBUTIONS) + ) { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION + } else { + displayYouWontSeeNearbyMessage() + } + } + } + }) + + private var shouldShowMediaDetailsFragment = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments != null && requireArguments().getString(ProfileActivity.KEY_USERNAME) != null) { + userName = requireArguments().getString(ProfileActivity.KEY_USERNAME) + isUserProfile = true + } + mSensorManager = requireActivity().getSystemService(Context.SENSOR_SERVICE) as SensorManager + mLight = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ORIENTATION) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentContributionsBinding.inflate(inflater, container, false) + + initWLMCampaign() + presenter!!.onAttachView(this) + contributionsPresenter!!.onAttachView(this) + binding!!.campaignsView.visibility = View.GONE + checkBoxView = View.inflate(activity, R.layout.nearby_permission_dialog, null) + checkBox = checkBoxView?.findViewById(R.id.never_ask_again) as CheckBox + checkBox!!.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean -> + if (isChecked) { + // Do not ask for permission on activity start again + store!!.putBoolean("displayLocationPermissionForCardView", false) + } + } + + if (savedInstanceState != null) { + mediaDetailPagerFragment = childFragmentManager + .findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG) as MediaDetailPagerFragment? + contributionsListFragment = childFragmentManager + .findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG) as ContributionsListFragment? + shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible") + } + + initFragments() + if (!isUserProfile) { + upDateUploadCount() + } + if (shouldShowMediaDetailsFragment) { + showMediaDetailPagerFragment() + } else { + if (mediaDetailPagerFragment != null) { + removeFragment(mediaDetailPagerFragment!!) + } + showContributionsListFragment() + } + + if (!isBetaFlavour && sessionManager!!.isUserLoggedIn + && sessionManager!!.currentAccount != null && !isUserProfile + ) { + setUploadCount() + } + setHasOptionsMenu(true) + return binding!!.root + } + + /** + * Initialise the campaign object for WML + */ + private fun initWLMCampaign() { + wlmCampaign = Campaign( + getString(R.string.wlm_campaign_title), + getString(R.string.wlm_campaign_description), Utils.getWLMStartDate().toString(), + Utils.getWLMEndDate().toString(), NearbyParentFragment.WLM_URL, true + ) + } + + override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater + ) { + // Removing contributions menu items for ProfileActivity + + if (activity is ProfileActivity) { + return + } + + inflater.inflate(R.menu.contribution_activity_notification_menu, menu) + + val notificationsMenuItem = menu.findItem(R.id.notifications) + val notification = notificationsMenuItem.actionView + notificationCount = notification!!.findViewById(R.id.notification_count_badge) + val uploadMenuItem = menu.findItem(R.id.upload_tab) + val uploadMenuItemActionView = uploadMenuItem.actionView + pendingUploadsCountTextView = uploadMenuItemActionView!!.findViewById( + R.id.pending_uploads_count_badge + ) + uploadsErrorTextView = uploadMenuItemActionView.findViewById( + R.id.uploads_error_count_badge + ) + pendingUploadsImageView = uploadMenuItemActionView.findViewById( + R.id.pending_uploads_image_view + ) + if (pendingUploadsImageView != null) { + pendingUploadsImageView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + if (pendingUploadsCountTextView != null) { + pendingUploadsCountTextView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + if (uploadsErrorTextView != null) { + uploadsErrorTextView!!.setOnClickListener { view: View? -> + startActivity( + Intent( + context, + UploadProgressActivity::class.java + ) + ) + } + } + notification.setOnClickListener { view: View? -> + startYourself( + context, "unread" + ) + } + } + + @SuppressLint("CheckResult") + fun setNotificationCount() { + compositeDisposable.add( + notificationController!!.getNotifications(false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { notificationList: List -> + this.initNotificationViews( + notificationList + ) + }, + { throwable: Throwable? -> + Timber.e( + throwable, + "Error occurred while loading notifications" + ) + }) + ) + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * Sets the visibility of the upload icon based on the number of failed and pending + * contributions. + */ + // public void setUploadIconVisibility() { + // contributionController.getFailedAndPendingContributions(); + // contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), + // list -> { + // updateUploadIcon(list.size()); + // }); + // } + /** + * Sets the count for the upload icon based on the number of pending and failed contributions. + */ + fun setUploadIconCount() { + contributionController!!.pendingContributions + contributionController!!.pendingContributionList!!.observe( + viewLifecycleOwner, + Observer> { list: PagedList -> + updatePendingIcon(list.size) + }) + contributionController!!.failedContributions + contributionController!!.failedContributionList!!.observe( + viewLifecycleOwner, + Observer> { list: PagedList -> + updateErrorIcon(list.size) + }) + } + + fun scrollToTop() { + if (contributionsListFragment != null) { + contributionsListFragment!!.scrollToTop() + } + } + + private fun initNotificationViews(notificationList: List) { + Timber.d("Number of notifications is %d", notificationList.size) + if (notificationList.isEmpty()) { + notificationCount!!.visibility = View.GONE + } else { + notificationCount!!.visibility = View.VISIBLE + notificationCount!!.text = notificationList.size.toString() + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + /* + - There are some operations we need auth, so we need to make sure isAuthCookieAcquired. + - And since we use same retained fragment doesn't want to make all network operations + all over again on same fragment attached to recreated activity, we do this network + operations on first time fragment attached to an activity. Then they will be retained + until fragment life time ends. + */ + if (!isFragmentAttachedBefore && activity != null) { + isFragmentAttachedBefore = true + } + } + + /** + * Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates + * new one if null. + */ + private fun showContributionsListFragment() { + // show nearby card view on contributions list is visible + if (binding!!.cardViewNearby != null && !isUserProfile) { + if (store!!.getBoolean("displayNearbyCardView", true)) { + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + binding!!.cardViewNearby.visibility = View.GONE + } + } + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + } + + private fun showMediaDetailPagerFragment() { + // hide nearby card view on media detail is visible + setupViewForMediaDetails() + showFragment( + mediaDetailPagerFragment!!, MEDIA_DETAIL_PAGER_FRAGMENT_TAG, + contributionsListFragment + ) + } + + private fun setupViewForMediaDetails() { + if (binding != null) { + binding!!.campaignsView.visibility = View.GONE + } + } + + override fun onBackStackChanged() { + fetchCampaigns() + } + + private fun initFragments() { + if (null == contributionsListFragment) { + contributionsListFragment = ContributionsListFragment() + val contributionsListBundle = Bundle() + contributionsListBundle.putString(ProfileActivity.KEY_USERNAME, userName) + contributionsListFragment!!.arguments = contributionsListBundle + } + + if (shouldShowMediaDetailsFragment) { + showMediaDetailPagerFragment() + } else { + showContributionsListFragment() + } + + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + } + + /** + * Replaces the root frame layout with the given fragment + * + * @param fragment + * @param tag + * @param otherFragment + */ + private fun showFragment(fragment: Fragment, tag: String, otherFragment: Fragment?) { + val transaction = childFragmentManager.beginTransaction() + if (fragment.isAdded && otherFragment != null) { + transaction.hide(otherFragment) + transaction.show(fragment) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (fragment.isAdded && otherFragment == null) { + transaction.show(fragment) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (!fragment.isAdded && otherFragment != null) { + transaction.hide(otherFragment) + transaction.add(R.id.root_frame, fragment, tag) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } else if (!fragment.isAdded) { + transaction.replace(R.id.root_frame, fragment, tag) + transaction.addToBackStack(tag) + transaction.commit() + childFragmentManager.executePendingTransactions() + } + } + + fun removeFragment(fragment: Fragment) { + childFragmentManager + .beginTransaction() + .remove(fragment) + .commit() + childFragmentManager.executePendingTransactions() + } + + private fun setUploadCount() { + okHttpJsonApiClient + ?.getUploadCount((activity as MainActivity).sessionManager?.currentAccount!!.name) + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread())?.let { + compositeDisposable.add( + it + .subscribe( + { uploadCount: Int -> this.displayUploadCount(uploadCount) }, + { t: Throwable? -> Timber.e(t, "Fetching upload count failed") } + )) + } + } + + private fun displayUploadCount(uploadCount: Int) { + if (requireActivity().isFinishing + || resources == null + ) { + return + } + + (activity as MainActivity).setNumOfUploads(uploadCount) + } + + override fun onPause() { + super.onPause() + locationManager!!.removeLocationListener(this) + locationManager!!.unregisterLocationManager() + mSensorManager!!.unregisterListener(this) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + } + + override fun onResume() { + super.onResume() + contributionsPresenter!!.onAttachView(this) + locationManager!!.addLocationListener(this) + + if (binding == null) { + return + } + + binding!!.cardViewNearby.permissionRequestButton.setOnClickListener { v: View? -> + showNearbyCardPermissionRationale() + } + + // Notification cards should only be seen on contributions list, not in media details + if (mediaDetailPagerFragment == null && !isUserProfile) { + if (store!!.getBoolean("displayNearbyCardView", true)) { + checkPermissionsAndShowNearbyCardView() + + // Calling nearby card to keep showing it even when user clicks on it and comes back + try { + updateClosestNearbyCardViewInfo() + } catch (e: Exception) { + Timber.e(e) + } + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + // Hide nearby notification card view if related shared preferences is false + binding!!.cardViewNearby.visibility = View.GONE + } + + // Notification Count and Campaigns should not be set, if it is used in User Profile + if (!isUserProfile) { + setNotificationCount() + fetchCampaigns() + // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + // setUploadIconVisibility(); + setUploadIconCount() + } + } + mSensorManager!!.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI) + } + + private fun checkPermissionsAndShowNearbyCardView() { + if (hasPermission( + requireActivity(), + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + ) { + onLocationPermissionGranted() + } else if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION) + && store!!.getBoolean("displayLocationPermissionForCardView", true) + && !store!!.getBoolean("doNotAskForLocationPermission", false) + && ((activity as MainActivity).activeFragment == ActiveFragment.CONTRIBUTIONS) + ) { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION + showNearbyCardPermissionRationale() + } + } + + private fun requestLocationPermission() { + nearbyLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION)) + } + + private fun onLocationPermissionGranted() { + binding!!.cardViewNearby.permissionType = + NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED + locationManager!!.registerLocationManager() + } + + private fun showNearbyCardPermissionRationale() { + showAlertDialog( + requireActivity(), + getString(R.string.nearby_card_permission_title), + getString(R.string.nearby_card_permission_explanation), + { this.requestLocationPermission() }, + { this.displayYouWontSeeNearbyMessage() }, + checkBoxView + ) + } + + private fun displayYouWontSeeNearbyMessage() { + showLongToast( + requireActivity(), + resources.getString(R.string.unable_to_display_nearest_place) + ) + // Set to true as the user doesn't want the app to ask for location permission anymore + store!!.putBoolean("doNotAskForLocationPermission", true) + } + + + private fun updateClosestNearbyCardViewInfo() { + currentLatLng = locationManager!!.getLastLocation() + compositeDisposable.add(Observable.fromCallable { + nearbyController?.loadAttractionsFromLocation( + currentLatLng, currentLatLng, true, + false + ) + } // thanks to boolean, it will only return closest result + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { nearbyPlacesInfo: NearbyPlacesInfo? -> + this.updateNearbyNotification( + nearbyPlacesInfo + ) + }, + { throwable: Throwable? -> + Timber.d(throwable) + updateNearbyNotification(null) + }) + ) + } + + private fun updateNearbyNotification( + nearbyPlacesInfo: NearbyPlacesInfo? + ) { + if (nearbyPlacesInfo?.placeList != null && nearbyPlacesInfo.placeList.size > 0) { + var closestNearbyPlace: Place? = null + // Find the first nearby place that has no image and exists + for (place in nearbyPlacesInfo.placeList) { + if (place.pic == "" && place.exists) { + closestNearbyPlace = place + break + } + } + + if (closestNearbyPlace == null) { + binding!!.cardViewNearby.visibility = View.GONE + } else { + val distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location) + closestNearbyPlace.setDistance(distance) + direction = computeBearing(currentLatLng!!, closestNearbyPlace.location).toFloat() + binding!!.cardViewNearby.updateContent(closestNearbyPlace) + } + } else { + // Means that no close nearby place is found + binding!!.cardViewNearby.visibility = View.GONE + } + + // Prevent Nearby banner from appearing in Media Details, fixing bug https://github.com/commons-app/apps-android-commons/issues/4731 + if (mediaDetailPagerFragment != null && !contributionsListFragment!!.isVisible) { + binding!!.cardViewNearby.visibility = View.GONE + } + } + + override fun onDestroy() { + try { + compositeDisposable.clear() + childFragmentManager.removeOnBackStackChangedListener(this) + locationManager!!.unregisterLocationManager() + locationManager!!.removeLocationListener(this) + super.onDestroy() + } catch (exception: IllegalArgumentException) { + Timber.e(exception) + } catch (exception: IllegalStateException) { + Timber.e(exception) + } + } + + override fun onLocationChangedSignificantly(latLng: LatLng) { + // Will be called if location changed more than 1000 meter + updateClosestNearbyCardViewInfo() + } + + override fun onLocationChangedSlightly(latLng: LatLng) { + /* Update closest nearby notification card onLocationChangedSlightly + */ + try { + updateClosestNearbyCardViewInfo() + } catch (e: Exception) { + Timber.e(e) + } + } + + override fun onLocationChangedMedium(latLng: LatLng) { + // Update closest nearby card view if location changed more than 500 meters + updateClosestNearbyCardViewInfo() + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { + super.onViewCreated(view, savedInstanceState) + } + + /** + * As the home screen has limited space, we have choosen to show either campaigns or WLM card. + * The WLM Card gets the priority over monuments, so if the WLM is going on we show that instead + * of campaigns on the campaigns card + */ + private fun fetchCampaigns() { + if (Utils.isMonumentsEnabled(Date())) { + if (binding != null) { + binding!!.campaignsView.setCampaign(wlmCampaign) + binding!!.campaignsView.visibility = View.VISIBLE + } + } else if (store!!.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) { + presenter!!.getCampaigns() + } else { + if (binding != null) { + binding!!.campaignsView.visibility = View.GONE + } + } + } + + override fun showMessage(message: String) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + + override fun showCampaigns(campaign: Campaign?) { + if (campaign != null && !isUserProfile) { + if (binding != null) { + binding!!.campaignsView.setCampaign(campaign) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + presenter!!.onDetachView() + } + + override fun notifyDataSetChanged() { + if (mediaDetailPagerFragment != null) { + mediaDetailPagerFragment!!.notifyDataSetChanged() + } + } + + /** + * Notify the viewpager that number of items have changed. + */ + override fun viewPagerNotifyDataSetChanged() { + if (mediaDetailPagerFragment != null) { + mediaDetailPagerFragment!!.notifyDataSetChanged() + } + } + + /** + * Updates the visibility and text of the pending uploads count TextView based on the given + * count. + * + * @param pendingCount The number of pending uploads. + */ + fun updatePendingIcon(pendingCount: Int) { + if (pendingUploadsCountTextView != null) { + if (pendingCount != 0) { + pendingUploadsCountTextView!!.visibility = View.VISIBLE + pendingUploadsCountTextView!!.text = pendingCount.toString() + } else { + pendingUploadsCountTextView!!.visibility = View.INVISIBLE + } + } + } + + /** + * Updates the visibility and text of the error uploads TextView based on the given count. + * + * @param errorCount The number of error uploads. + */ + fun updateErrorIcon(errorCount: Int) { + if (uploadsErrorTextView != null) { + if (errorCount != 0) { + uploadsErrorTextView!!.visibility = View.VISIBLE + uploadsErrorTextView!!.text = errorCount.toString() + } else { + uploadsErrorTextView!!.visibility = View.GONE + } + } + } + + /** + * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] + * @param count The number of pending uploads. + */ + // public void updateUploadIcon(int count) { + // if (pendingUploadsImageView != null) { + // if (count != 0) { + // pendingUploadsImageView.setVisibility(View.VISIBLE); + // } else { + // pendingUploadsImageView.setVisibility(View.GONE); + // } + // } + // } + /** + * Replace whatever is in the current contributionsFragmentContainer view with + * mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects + * a contribution. + */ + override fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean) { + if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment!!.isVisible) { + mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true) + if (isUserProfile) { + (activity as ProfileActivity).setScroll(false) + } + showMediaDetailPagerFragment() + } + mediaDetailPagerFragment!!.showImage(position, isWikipediaButtonDisplayed) + } + + override fun getMediaAtPosition(i: Int): Media? { + return contributionsListFragment!!.getMediaAtPosition(i) + } + + override fun getTotalMediaCount(): Int { + return contributionsListFragment!!.totalMediaCount + } + + override fun getContributionStateAt(position: Int): Int { + return contributionsListFragment!!.getContributionStateAt(position) + } + + fun backButtonClicked(): Boolean { + if (mediaDetailPagerFragment != null && mediaDetailPagerFragment!!.isVisible) { + if (store!!.getBoolean("displayNearbyCardView", true) && !isUserProfile) { + if (binding!!.cardViewNearby.cardViewVisibilityState + == NearbyNotificationCardView.CardViewVisibilityState.READY + ) { + binding!!.cardViewNearby.visibility = View.VISIBLE + } + } else { + binding!!.cardViewNearby.visibility = View.GONE + } + removeFragment(mediaDetailPagerFragment!!) + showFragment( + contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG, + mediaDetailPagerFragment + ) + if (isUserProfile) { + // Fragment is associated with ProfileActivity + // Enable ParentViewPager Scroll + (activity as ProfileActivity).setScroll(true) + } else { + fetchCampaigns() + } + if (activity is MainActivity) { + // Fragment is associated with MainActivity + (activity as BaseActivity).supportActionBar + ?.setDisplayHomeAsUpEnabled(false) + (activity as MainActivity).showTabs() + } + return true + } + return false + } + + + /** + * this function updates the number of contributions + */ + fun upDateUploadCount() { + WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(UploadWorker::class.java.simpleName).observe( + viewLifecycleOwner + ) { workInfos: List -> + if (workInfos.size > 0) { + setUploadCount() + } + } + } + + + /** + * Restarts the upload process for a contribution + * + * @param contribution + */ + fun restartUpload(contribution: Contribution) { + contribution.dateUploadStarted = Calendar.getInstance().time + if (contribution.state == Contribution.STATE_FAILED) { + if (contribution.errorInfo == null) { + contribution.chunkInfo = null + contribution.transferred = 0 + } + contributionsPresenter!!.checkDuplicateImageAndRestartContribution(contribution) + } else { + contribution.state = Contribution.STATE_QUEUED + contributionsPresenter!!.saveContribution(contribution) + Timber.d("Restarting for %s", contribution.toString()) + } + } + + /** + * Retry upload when it is failed + * + * @param contribution contribution to be retried + */ + fun retryUpload(contribution: Contribution) { + if (isInternetConnectionEstablished(context)) { + if (contribution.state == Contribution.STATE_PAUSED) { + restartUpload(contribution) + } else if (contribution.state == Contribution.STATE_FAILED) { + val retries = contribution.retries + // TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562 + /* Limit the number of retries for a failed upload + to handle cases like invalid filename as such uploads + will never be successful */ + if (retries < MAX_RETRIES) { + contribution.retries = retries + 1 + Timber.d( + "Retried uploading %s %d times", contribution.media.filename, + retries + 1 + ) + restartUpload(contribution) + } else { + // TODO: Show the exact reason for failure + Toast.makeText( + context, + R.string.retry_limit_reached, Toast.LENGTH_SHORT + ).show() + } + } else { + Timber.d("Skipping re-upload for non-failed %s", contribution.toString()) + } + } else { + showLongToast(context, R.string.this_function_needs_network_connection) + } + } + + /** + * Reload media detail fragment once media is nominated + * + * @param index item position that has been nominated + */ + override fun refreshNominatedMedia(index: Int) { + if (mediaDetailPagerFragment != null && !contributionsListFragment!!.isVisible) { + removeFragment(mediaDetailPagerFragment!!) + mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true) + mediaDetailPagerFragment?.showImage(index) + showMediaDetailPagerFragment() + } + } + + /** + * When the device rotates, rotate the Nearby banner's compass arrow in tandem. + */ + override fun onSensorChanged(event: SensorEvent) { + val rotateDegree = Math.round(event.values[0]).toFloat() + binding!!.cardViewNearby.rotateCompass(rotateDegree, direction) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { + // Nothing to do. + } + + companion object { + private const val CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag" + const val MEDIA_DETAIL_PAGER_FRAGMENT_TAG: String = "MediaDetailFragmentTag" + private const val MAX_RETRIES = 10 + + @JvmStatic + fun newInstance(): ContributionsFragment { + val fragment = ContributionsFragment() + fragment.retainInstance = true + return fragment + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java deleted file mode 100644 index 3f9e8d541..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java +++ /dev/null @@ -1,77 +0,0 @@ - package fr.free.nrw.commons.contributions; - -import android.view.LayoutInflater; -import android.view.ViewGroup; -import androidx.annotation.NonNull; -import androidx.paging.PagedListAdapter; -import androidx.recyclerview.widget.DiffUtil; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.media.MediaClient; - - /** - * Represents The View Adapter for the List of Contributions - */ -public class ContributionsListAdapter extends - PagedListAdapter { - - private final Callback callback; - private final MediaClient mediaClient; - - ContributionsListAdapter(final Callback callback, - final MediaClient mediaClient) { - super(DIFF_CALLBACK); - this.callback = callback; - this.mediaClient = mediaClient; - } - - /** - * Uses DiffUtil to calculate the changes in the list - * It has methods that check ID and the content of the items to determine if its a new item - */ - private static final DiffUtil.ItemCallback DIFF_CALLBACK = - new DiffUtil.ItemCallback() { - @Override - public boolean areItemsTheSame(final Contribution oldContribution, final Contribution newContribution) { - return oldContribution.getPageId().equals(newContribution.getPageId()); - } - - @Override - public boolean areContentsTheSame(final Contribution oldContribution, final Contribution newContribution) { - return oldContribution.equals(newContribution); - } - }; - - /** - * Initializes the view holder with contribution data - */ - @Override - public void onBindViewHolder(@NonNull ContributionViewHolder holder, int position) { - holder.init(position, getItem(position)); - } - - Contribution getContributionForPosition(final int position) { - return getItem(position); - } - - /** - * Creates the new View Holder which will be used to display items(contributions) using the - * onBindViewHolder(viewHolder,position) - */ - @NonNull - @Override - public ContributionViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - final ContributionViewHolder viewHolder = new ContributionViewHolder( - LayoutInflater.from(parent.getContext()) - .inflate(R.layout.layout_contribution, parent, false), - callback, mediaClient); - return viewHolder; - } - - public interface Callback { - - void openMediaDetail(int contribution, boolean isWikipediaPageExists); - - void addImageToWikipedia(Contribution contribution); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt new file mode 100644 index 000000000..b41de1c6e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt @@ -0,0 +1,72 @@ +package fr.free.nrw.commons.contributions + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.DiffUtil +import fr.free.nrw.commons.R +import fr.free.nrw.commons.media.MediaClient + +/** + * Represents The View Adapter for the List of Contributions + */ +class ContributionsListAdapter internal constructor( + private val callback: Callback, + private val mediaClient: MediaClient +) : PagedListAdapter(DIFF_CALLBACK) { + /** + * Initializes the view holder with contribution data + */ + override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) { + holder.init(position, getItem(position)) + } + + fun getContributionForPosition(position: Int): Contribution? { + return getItem(position) + } + + /** + * Creates the new View Holder which will be used to display items(contributions) using the + * onBindViewHolder(viewHolder,position) + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ContributionViewHolder { + val viewHolder = ContributionViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.layout_contribution, parent, false), + callback, mediaClient + ) + return viewHolder + } + + interface Callback { + fun openMediaDetail(contribution: Int, isWikipediaPageExists: Boolean) + + fun addImageToWikipedia(contribution: Contribution?) + } + + companion object { + /** + * Uses DiffUtil to calculate the changes in the list + * It has methods that check ID and the content of the items to determine if its a new item + */ + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldContribution: Contribution, + newContribution: Contribution + ): Boolean { + return oldContribution.pageId == newContribution.pageId + } + + override fun areContentsTheSame( + oldContribution: Contribution, + newContribution: Contribution + ): Boolean { + return oldContribution == newContribution + } + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java deleted file mode 100644 index 0d0a19436..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java +++ /dev/null @@ -1,25 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import fr.free.nrw.commons.BasePresenter; - -/** - * The contract for Contributions list View & Presenter - */ -public class ContributionsListContract { - - public interface View { - - void showWelcomeTip(boolean numberOfUploads); - - void showProgress(boolean shouldShow); - - void showNoContributionsUI(boolean shouldShow); - } - - public interface UserActionListener extends BasePresenter { - - void refreshList(SwipeRefreshLayout swipeRefreshLayout); - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt new file mode 100644 index 000000000..c6b8dd8a8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.contributions + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import fr.free.nrw.commons.BasePresenter + +/** + * The contract for Contributions list View & Presenter + */ +class ContributionsListContract { + interface View { + fun showWelcomeTip(numberOfUploads: Boolean) + + fun showProgress(shouldShow: Boolean) + + fun showNoContributionsUI(shouldShow: Boolean) + } + + interface UserActionListener : BasePresenter { + fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java deleted file mode 100644 index df65a91cc..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ /dev/null @@ -1,534 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static android.view.View.GONE; -import static android.view.View.VISIBLE; -import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE; - -import android.Manifest.permission; -import android.content.Context; -import android.content.Intent; -import android.content.res.Configuration; -import android.net.Uri; -import android.os.Bundle; -import android.os.Parcelable; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.LinearLayout; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.fragment.app.FragmentManager; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; -import androidx.recyclerview.widget.RecyclerView.ItemAnimator; -import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; -import androidx.recyclerview.widget.SimpleItemAnimator; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback; -import fr.free.nrw.commons.databinding.FragmentContributionsListBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.media.MediaClient; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.SystemThemeUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import java.util.Map; -import java.util.Objects; -import javax.inject.Inject; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import fr.free.nrw.commons.wikidata.model.WikiSite; - - -/** - * Created by root on 01.06.2018. - */ - -public class ContributionsListFragment extends CommonsDaggerSupportFragment implements - ContributionsListContract.View, Callback, - WikipediaInstructionsDialogFragment.Callback { - - private static final String RV_STATE = "rv_scroll_state"; - - @Inject - SystemThemeUtils systemThemeUtils; - @Inject - ContributionController controller; - @Inject - MediaClient mediaClient; - @Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) - @Inject - WikiSite languageWikipediaSite; - @Inject - ContributionsListPresenter contributionsListPresenter; - @Inject - SessionManager sessionManager; - - private FragmentContributionsListBinding binding; - private Animation fab_close; - private Animation fab_open; - private Animation rotate_forward; - private Animation rotate_backward; - private boolean isFabOpen; - @VisibleForTesting - protected RecyclerView rvContributionsList; - - @VisibleForTesting - protected ContributionsListAdapter adapter; - - @Nullable - @VisibleForTesting - protected Callback callback; - - private final int SPAN_COUNT_LANDSCAPE = 3; - private final int SPAN_COUNT_PORTRAIT = 1; - - private int contributionsSize; - private String userName; - - private final ActivityResultLauncher galleryPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); - }); - }); - - private final ActivityResultLauncher customSelectorLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); - }); - }); - - private final ActivityResultLauncher cameraPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); - }); - }); - - private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( - new RequestMultiplePermissions(), - new ActivityResultCallback>() { - @Override - public void onActivityResult(Map result) { - boolean areAllGranted = true; - for (final boolean b : result.values()) { - areAllGranted = areAllGranted && b; - } - - if (areAllGranted) { - controller.locationPermissionCallback.onLocationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - controller.handleShowRationaleFlowCameraLocation(getActivity(), - inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); - } else { - controller.locationPermissionCallback.onLocationPermissionDenied( - getActivity().getString( - R.string.in_app_camera_location_permission_denied)); - } - } - } - }); - - - @Override - public void onCreate( - @Nullable @org.jetbrains.annotations.Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - //Now that we are allowing this fragment to be started for - // any userName- we expect it to be passed as an argument - if (getArguments() != null) { - userName = getArguments().getString(ProfileActivity.KEY_USERNAME); - } - - if (StringUtils.isEmpty(userName)) { - userName = sessionManager.getUserName(); - } - } - - @Override - public View onCreateView( - final LayoutInflater inflater, @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentContributionsListBinding.inflate( - inflater, container, false - ); - rvContributionsList = binding.contributionsList; - - contributionsListPresenter.onAttachView(this); - binding.fabCustomGallery.setOnClickListener(v -> launchCustomSelector()); - binding.fabCustomGallery.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(), R.string.custom_selector_title); - return true; - }); - - if (Objects.equals(sessionManager.getUserName(), userName)) { - binding.tvContributionsOfUser.setVisibility(GONE); - binding.fabLayout.setVisibility(VISIBLE); - } else { - binding.tvContributionsOfUser.setVisibility(VISIBLE); - binding.tvContributionsOfUser.setText( - getString(R.string.contributions_of_user, userName)); - binding.fabLayout.setVisibility(GONE); - } - - initAdapter(); - - // pull down to refresh only enabled for self user. - if(Objects.equals(sessionManager.getUserName(), userName)){ - binding.swipeRefreshLayout.setOnRefreshListener(() -> { - contributionsListPresenter.refreshList(binding.swipeRefreshLayout); - }); - } else { - binding.swipeRefreshLayout.setEnabled(false); - } - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - binding = null; - super.onDestroyView(); - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - if (getParentFragment() != null && getParentFragment() instanceof ContributionsFragment) { - callback = ((ContributionsFragment) getParentFragment()); - } - } - - @Override - public void onDetach() { - super.onDetach(); - callback = null;//To avoid possible memory leak - } - - private void initAdapter() { - adapter = new ContributionsListAdapter(this, mediaClient); - } - - @Override - public void onViewCreated(final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - initRecyclerView(); - initializeAnimations(); - setListeners(); - } - - private void initRecyclerView() { - final GridLayoutManager layoutManager = new GridLayoutManager(getContext(), - getSpanCount(getResources().getConfiguration().orientation)); - rvContributionsList.setLayoutManager(layoutManager); - - //Setting flicker animation of recycler view to false. - final ItemAnimator animator = rvContributionsList.getItemAnimator(); - if (animator instanceof SimpleItemAnimator) { - ((SimpleItemAnimator) animator).setSupportsChangeAnimations(false); - } - - contributionsListPresenter.setup(userName, - Objects.equals(sessionManager.getUserName(), userName)); - contributionsListPresenter.contributionList.observe(getViewLifecycleOwner(), list -> { - contributionsSize = list.size(); - adapter.submitList(list); - if (callback != null) { - callback.notifyDataSetChanged(); - } - }); - rvContributionsList.setAdapter(adapter); - adapter.registerAdapterDataObserver(new AdapterDataObserver() { - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - super.onItemRangeInserted(positionStart, itemCount); - contributionsSize = adapter.getItemCount(); - if (callback != null) { - callback.notifyDataSetChanged(); - } - if (itemCount > 0 && positionStart == 0) { - if (adapter.getContributionForPosition(positionStart) != null) { - rvContributionsList - .scrollToPosition(0);//Newly upload items are always added to the top - } - } - } - - /** - * Called whenever items in the list have changed - * Calls viewPagerNotifyDataSetChanged() that will notify the viewpager - */ - @Override - public void onItemRangeChanged(final int positionStart, final int itemCount) { - super.onItemRangeChanged(positionStart, itemCount); - if (callback != null) { - callback.viewPagerNotifyDataSetChanged(); - } - } - }); - - //Fab close on touch outside (Scrolling or taping on item triggers this action). - rvContributionsList.addOnItemTouchListener(new OnItemTouchListener() { - - /** - * Silently observe and/or take over touch events sent to the RecyclerView before - * they are handled by either the RecyclerView itself or its child views. - */ - @Override - public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - if (e.getAction() == MotionEvent.ACTION_DOWN) { - if (isFabOpen) { - animateFAB(isFabOpen); - } - } - return false; - } - - /** - * Process a touch event as part of a gesture that was claimed by returning true - * from a previous call to {@link #onInterceptTouchEvent}. - * - * @param rv - * @param e MotionEvent describing the touch event. All coordinates are in the - * RecyclerView's coordinate system. - */ - @Override - public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - //required abstract method DO NOT DELETE - } - - /** - * Called when a child of RecyclerView does not want RecyclerView and its ancestors - * to intercept touch events with {@link ViewGroup#onInterceptTouchEvent(MotionEvent)}. - * - * @param disallowIntercept True if the child does not want the parent to intercept - * touch events. - */ - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - //required abstract method DO NOT DELETE - } - - }); - } - - private int getSpanCount(final int orientation) { - return orientation == Configuration.ORIENTATION_LANDSCAPE ? - SPAN_COUNT_LANDSCAPE : SPAN_COUNT_PORTRAIT; - } - - @Override - public void onConfigurationChanged(final Configuration newConfig) { - super.onConfigurationChanged(newConfig); - // check orientation - binding.fabLayout.setOrientation( - newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? - LinearLayout.HORIZONTAL : LinearLayout.VERTICAL); - rvContributionsList - .setLayoutManager( - new GridLayoutManager(getContext(), getSpanCount(newConfig.orientation))); - } - - private void initializeAnimations() { - fab_open = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_open); - fab_close = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_close); - rotate_forward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_forward); - rotate_backward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_backward); - } - - private void setListeners() { - binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); - binding.fabCamera.setOnClickListener(view -> { - controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); - animateFAB(isFabOpen); - }); - binding.fabCamera.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(), R.string.add_contribution_from_camera); - return true; - }); - binding.fabGallery.setOnClickListener(view -> { - controller.initiateGalleryPick(getActivity(), galleryPickLauncherForResult, true); - animateFAB(isFabOpen); - }); - binding.fabGallery.setOnLongClickListener(view -> { - ViewUtil.showShortToast(getContext(), R.string.menu_from_gallery); - return true; - }); - } - - /** - * Launch Custom Selector. - */ - protected void launchCustomSelector() { - controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult); - animateFAB(isFabOpen); - } - - public void scrollToTop() { - rvContributionsList.smoothScrollToPosition(0); - } - - private void animateFAB(final boolean isFabOpen) { - this.isFabOpen = !isFabOpen; - if (binding.fabPlus.isShown()) { - if (isFabOpen) { - binding.fabPlus.startAnimation(rotate_backward); - binding.fabCamera.startAnimation(fab_close); - binding.fabGallery.startAnimation(fab_close); - binding.fabCustomGallery.startAnimation(fab_close); - binding.fabCamera.hide(); - binding.fabGallery.hide(); - binding.fabCustomGallery.hide(); - } else { - binding.fabPlus.startAnimation(rotate_forward); - binding.fabCamera.startAnimation(fab_open); - binding.fabGallery.startAnimation(fab_open); - binding.fabCustomGallery.startAnimation(fab_open); - binding.fabCamera.show(); - binding.fabGallery.show(); - binding.fabCustomGallery.show(); - } - this.isFabOpen = !isFabOpen; - } - } - - /** - * Shows welcome message if user has no contributions yet i.e. new user. - */ - @Override - public void showWelcomeTip(final boolean shouldShow) { - binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); - } - - /** - * Responsible to set progress bar invisible and visible - * - * @param shouldShow True when contributions list should be hidden. - */ - @Override - public void showProgress(final boolean shouldShow) { - binding.loadingContributionsProgressBar.setVisibility(shouldShow ? VISIBLE : GONE); - } - - @Override - public void showNoContributionsUI(final boolean shouldShow) { - binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE); - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - final GridLayoutManager layoutManager = (GridLayoutManager) rvContributionsList - .getLayoutManager(); - outState.putParcelable(RV_STATE, layoutManager.onSaveInstanceState()); - } - - @Override - public void onViewStateRestored(@Nullable Bundle savedInstanceState) { - super.onViewStateRestored(savedInstanceState); - if (null != savedInstanceState) { - final Parcelable savedRecyclerLayoutState = savedInstanceState.getParcelable(RV_STATE); - rvContributionsList.getLayoutManager().onRestoreInstanceState(savedRecyclerLayoutState); - } - } - - @Override - public void openMediaDetail(final int position, boolean isWikipediaButtonDisplayed) { - if (null != callback) {//Just being safe, ideally they won't be called when detached - callback.showDetail(position, isWikipediaButtonDisplayed); - } - } - - /** - * Handle callback for wikipedia icon clicked - * - * @param contribution - */ - @Override - public void addImageToWikipedia(Contribution contribution) { - DialogUtil.showAlertDialog(getActivity(), - getString(R.string.add_picture_to_wikipedia_article_title), - getString(R.string.add_picture_to_wikipedia_article_desc), - () -> { - showAddImageToWikipediaInstructions(contribution); - }, () -> { - // do nothing - }); - } - - /** - * Display confirmation dialog with instructions when the user tries to add image to wikipedia - * - * @param contribution - */ - private void showAddImageToWikipediaInstructions(Contribution contribution) { - FragmentManager fragmentManager = getFragmentManager(); - WikipediaInstructionsDialogFragment fragment = WikipediaInstructionsDialogFragment - .newInstance(contribution); - fragment.setCallback(this::onConfirmClicked); - fragment.show(fragmentManager, "WikimediaFragment"); - } - - - public Media getMediaAtPosition(final int i) { - if (adapter.getContributionForPosition(i) != null) { - return adapter.getContributionForPosition(i).getMedia(); - } - return null; - } - - public int getTotalMediaCount() { - return contributionsSize; - } - - /** - * Open the editor for the language Wikipedia - * - * @param contribution - */ - @Override - public void onConfirmClicked(@Nullable Contribution contribution, boolean copyWikicode) { - if (copyWikicode) { - String wikicode = contribution.getMedia().getWikiCode(); - Utils.copy("wikicode", wikicode, getContext()); - } - - final String url = - languageWikipediaSite.mobileUrl() + "/wiki/" + contribution.getWikidataPlace() - .getWikipediaPageTitle(); - Utils.handleWebUrl(getContext(), Uri.parse(url)); - } - - public Integer getContributionStateAt(int position) { - return adapter.getContributionForPosition(position).getState(); - } - - public interface Callback { - - void notifyDataSetChanged(); - - void showDetail(int position, boolean isWikipediaButtonDisplayed); - - // Notify the viewpager that number of items have changed. - void viewPagerNotifyDataSetChanged(); - - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt new file mode 100644 index 000000000..bfe1161c7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt @@ -0,0 +1,551 @@ +package fr.free.nrw.commons.contributions + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.LinearLayout +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.VisibleForTesting +import androidx.paging.PagedList +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener +import androidx.recyclerview.widget.SimpleItemAnimator +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.contributions.WikipediaInstructionsDialogFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.FragmentContributionsListBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.di.NetworkingModule +import fr.free.nrw.commons.filepicker.FilePicker +import fr.free.nrw.commons.media.MediaClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.SystemThemeUtils +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.wikidata.model.WikiSite +import org.apache.commons.lang3.StringUtils +import javax.inject.Inject +import javax.inject.Named + + +/** + * Created by root on 01.06.2018. + */ +class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsListContract.View, + ContributionsListAdapter.Callback, WikipediaInstructionsDialogFragment.Callback { + @JvmField + @Inject + var systemThemeUtils: SystemThemeUtils? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var mediaClient: MediaClient? = null + + @JvmField + @Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) + @Inject + var languageWikipediaSite: WikiSite? = null + + @JvmField + @Inject + var contributionsListPresenter: ContributionsListPresenter? = null + + @JvmField + @Inject + var sessionManager: SessionManager? = null + + private var binding: FragmentContributionsListBinding? = null + private var fab_close: Animation? = null + private var fab_open: Animation? = null + private var rotate_forward: Animation? = null + private var rotate_backward: Animation? = null + private var isFabOpen = false + + private lateinit var inAppCameraLocationPermissionLauncher: ActivityResultLauncher> + + @VisibleForTesting + var rvContributionsList: RecyclerView? = null + + @VisibleForTesting + var adapter: ContributionsListAdapter? = null + + @VisibleForTesting + var callback: Callback? = null + + private val SPAN_COUNT_LANDSCAPE = 3 + private val SPAN_COUNT_PORTRAIT = 1 + + private var contributionsSize = 0 + private var userName: String? = null + + private val galleryPickLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromGallery( + result!!, requireActivity(), callbacks!! + ) + } + } + + private val customSelectorLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromCustomSelector( + result!!, requireActivity(), callbacks!! + ) + } + } + + private val cameraPickLauncherForResult = registerForActivityResult( + StartActivityForResult() + ) { result: ActivityResult? -> + controller!!.handleActivityResultWithCallback(requireActivity() + ) { callbacks: FilePicker.Callbacks? -> + controller!!.onPictureReturnedFromCamera( + result!!, requireActivity(), callbacks!! + ) + } + } + + @SuppressLint("NewApi") + override fun onCreate( + savedInstanceState: Bundle? + ) { + super.onCreate(savedInstanceState) + //Now that we are allowing this fragment to be started for + // any userName- we expect it to be passed as an argument + if (arguments != null) { + userName = requireArguments().getString(ProfileActivity.KEY_USERNAME) + } + + if (StringUtils.isEmpty(userName)) { + userName = sessionManager!!.userName + } + inAppCameraLocationPermissionLauncher = + registerForActivityResult(RequestMultiplePermissions()) { result -> + val areAllGranted = result.values.all { it } + + if (areAllGranted) { + controller?.locationPermissionCallback?.onLocationPermissionGranted() + } else { + activity?.let { currentActivity -> + if (currentActivity.shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { + controller?.handleShowRationaleFlowCameraLocation( + currentActivity, + inAppCameraLocationPermissionLauncher, // Pass launcher + cameraPickLauncherForResult + ) + } else { + controller?.locationPermissionCallback?.onLocationPermissionDenied( + currentActivity.getString(R.string.in_app_camera_location_permission_denied) + ) + } + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentContributionsListBinding.inflate( + inflater, container, false + ) + rvContributionsList = binding!!.contributionsList + + contributionsListPresenter!!.onAttachView(this) + binding!!.fabCustomGallery.setOnClickListener { v: View? -> launchCustomSelector() } + binding!!.fabCustomGallery.setOnLongClickListener { view: View? -> + showShortToast(context, fr.free.nrw.commons.R.string.custom_selector_title) + true + } + + if (sessionManager!!.userName == userName) { + binding!!.tvContributionsOfUser.visibility = View.GONE + binding!!.fabLayout.visibility = View.VISIBLE + } else { + binding!!.tvContributionsOfUser.visibility = View.VISIBLE + binding!!.tvContributionsOfUser.text = + getString(fr.free.nrw.commons.R.string.contributions_of_user, userName) + binding!!.fabLayout.visibility = View.GONE + } + + initAdapter() + + // pull down to refresh only enabled for self user. + if (sessionManager!!.userName == userName) { + binding!!.swipeRefreshLayout.setOnRefreshListener { + contributionsListPresenter!!.refreshList( + binding!!.swipeRefreshLayout + ) + } + } else { + binding!!.swipeRefreshLayout.isEnabled = false + } + + return binding!!.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + if (parentFragment != null && parentFragment is ContributionsFragment) { + callback = (parentFragment as ContributionsFragment) + } + } + + override fun onDetach() { + super.onDetach() + callback = null //To avoid possible memory leak + } + + private fun initAdapter() { + adapter = ContributionsListAdapter(this, mediaClient!!) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRecyclerView() + initializeAnimations() + setListeners() + } + + private fun initRecyclerView() { + val layoutManager = GridLayoutManager( + context, + getSpanCount(resources.configuration.orientation) + ) + rvContributionsList!!.layoutManager = layoutManager + + //Setting flicker animation of recycler view to false. + val animator = rvContributionsList!!.itemAnimator + if (animator is SimpleItemAnimator) { + animator.supportsChangeAnimations = false + } + + contributionsListPresenter!!.setup( + userName, + sessionManager!!.userName == userName + ) + contributionsListPresenter!!.contributionList?.observe( + viewLifecycleOwner + ) { list: PagedList? -> + if (list != null) { + contributionsSize = list.size + } + adapter!!.submitList(list) + if (callback != null) { + callback!!.notifyDataSetChanged() + } + } + rvContributionsList!!.adapter = adapter + adapter!!.registerAdapterDataObserver(object : AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + contributionsSize = adapter!!.itemCount + if (callback != null) { + callback!!.notifyDataSetChanged() + } + if (itemCount > 0 && positionStart == 0) { + if (adapter!!.getContributionForPosition(positionStart) != null) { + rvContributionsList!! + .scrollToPosition(0) //Newly upload items are always added to the top + } + } + } + + /** + * Called whenever items in the list have changed + * Calls viewPagerNotifyDataSetChanged() that will notify the viewpager + */ + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + super.onItemRangeChanged(positionStart, itemCount) + if (callback != null) { + callback!!.viewPagerNotifyDataSetChanged() + } + } + }) + + //Fab close on touch outside (Scrolling or taping on item triggers this action). + rvContributionsList!!.addOnItemTouchListener(object : OnItemTouchListener { + /** + * Silently observe and/or take over touch events sent to the RecyclerView before + * they are handled by either the RecyclerView itself or its child views. + */ + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + if (e.action == MotionEvent.ACTION_DOWN) { + if (isFabOpen) { + animateFAB(isFabOpen) + } + } + return false + } + + /** + * Process a touch event as part of a gesture that was claimed by returning true + * from a previous call to [.onInterceptTouchEvent]. + * + * @param rv + * @param e MotionEvent describing the touch event. All coordinates are in the + * RecyclerView's coordinate system. + */ + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + //required abstract method DO NOT DELETE + } + + /** + * Called when a child of RecyclerView does not want RecyclerView and its ancestors + * to intercept touch events with [ViewGroup.onInterceptTouchEvent]. + * + * @param disallowIntercept True if the child does not want the parent to intercept + * touch events. + */ + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + //required abstract method DO NOT DELETE + } + }) + } + + private fun getSpanCount(orientation: Int): Int { + return if (orientation == Configuration.ORIENTATION_LANDSCAPE) SPAN_COUNT_LANDSCAPE else SPAN_COUNT_PORTRAIT + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // check orientation + binding!!.fabLayout.orientation = + if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL + rvContributionsList + ?.setLayoutManager( + GridLayoutManager(context, getSpanCount(newConfig.orientation)) + ) + } + + private fun initializeAnimations() { + fab_open = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.fab_open) + fab_close = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.fab_close) + rotate_forward = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.rotate_forward) + rotate_backward = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.rotate_backward) + } + + private fun setListeners() { + binding!!.fabPlus.setOnClickListener { view: View? -> animateFAB(isFabOpen) } + binding!!.fabCamera.setOnClickListener { view: View? -> + controller!!.initiateCameraPick( + requireActivity(), + inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult + ) + animateFAB(isFabOpen) + } + binding!!.fabCamera.setOnLongClickListener { view: View? -> + showShortToast( + context, + fr.free.nrw.commons.R.string.add_contribution_from_camera + ) + true + } + binding!!.fabGallery.setOnClickListener { view: View? -> + controller!!.initiateGalleryPick(requireActivity(), galleryPickLauncherForResult, true) + animateFAB(isFabOpen) + } + binding!!.fabGallery.setOnLongClickListener { view: View? -> + showShortToast(context, fr.free.nrw.commons.R.string.menu_from_gallery) + true + } + } + + /** + * Launch Custom Selector. + */ + protected fun launchCustomSelector() { + controller!!.initiateCustomGalleryPickWithPermission( + requireActivity(), + customSelectorLauncherForResult + ) + animateFAB(isFabOpen) + } + + fun scrollToTop() { + rvContributionsList!!.smoothScrollToPosition(0) + } + + private fun animateFAB(isFabOpen: Boolean) { + this.isFabOpen = !isFabOpen + if (binding!!.fabPlus.isShown) { + if (isFabOpen) { + binding!!.fabPlus.startAnimation(rotate_backward) + binding!!.fabCamera.startAnimation(fab_close) + binding!!.fabGallery.startAnimation(fab_close) + binding!!.fabCustomGallery.startAnimation(fab_close) + binding!!.fabCamera.hide() + binding!!.fabGallery.hide() + binding!!.fabCustomGallery.hide() + } else { + binding!!.fabPlus.startAnimation(rotate_forward) + binding!!.fabCamera.startAnimation(fab_open) + binding!!.fabGallery.startAnimation(fab_open) + binding!!.fabCustomGallery.startAnimation(fab_open) + binding!!.fabCamera.show() + binding!!.fabGallery.show() + binding!!.fabCustomGallery.show() + } + this.isFabOpen = !isFabOpen + } + } + + /** + * Shows welcome message if user has no contributions yet i.e. new user. + */ + override fun showWelcomeTip(shouldShow: Boolean) { + binding!!.noContributionsYet.visibility = + if (shouldShow) View.VISIBLE else View.GONE + } + + /** + * Responsible to set progress bar invisible and visible + * + * @param shouldShow True when contributions list should be hidden. + */ + override fun showProgress(shouldShow: Boolean) { + binding!!.loadingContributionsProgressBar.visibility = + if (shouldShow) View.VISIBLE else View.GONE + } + + override fun showNoContributionsUI(shouldShow: Boolean) { + binding!!.noContributionsYet.visibility = + if (shouldShow) View.VISIBLE else View.GONE + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + val layoutManager = rvContributionsList + ?.getLayoutManager() as GridLayoutManager? + outState.putParcelable(RV_STATE, layoutManager!!.onSaveInstanceState()) + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + if (null != savedInstanceState) { + val savedRecyclerLayoutState = savedInstanceState.getParcelable(RV_STATE) + rvContributionsList!!.layoutManager!!.onRestoreInstanceState(savedRecyclerLayoutState) + } + } + + override fun openMediaDetail(position: Int, isWikipediaButtonDisplayed: Boolean) { + if (null != callback) { //Just being safe, ideally they won't be called when detached + callback!!.showDetail(position, isWikipediaButtonDisplayed) + } + } + + /** + * Handle callback for wikipedia icon clicked + * + * @param contribution + */ + override fun addImageToWikipedia(contribution: Contribution?) { + showAlertDialog( + requireActivity(), + getString(fr.free.nrw.commons.R.string.add_picture_to_wikipedia_article_title), + getString(fr.free.nrw.commons.R.string.add_picture_to_wikipedia_article_desc), + { + if (contribution != null) { + showAddImageToWikipediaInstructions(contribution) + } + }, {}) + } + + /** + * Display confirmation dialog with instructions when the user tries to add image to wikipedia + * + * @param contribution + */ + private fun showAddImageToWikipediaInstructions(contribution: Contribution) { + val fragmentManager = fragmentManager + val fragment = newInstance(contribution) + fragment.callback = + WikipediaInstructionsDialogFragment.Callback { contribution: Contribution?, copyWikicode: Boolean -> + this.onConfirmClicked( + contribution, + copyWikicode + ) + } + fragment.show(fragmentManager!!, "WikimediaFragment") + } + + + fun getMediaAtPosition(i: Int): Media? { + if (adapter!!.getContributionForPosition(i) != null) { + return adapter!!.getContributionForPosition(i)!!.media + } + return null + } + + val totalMediaCount: Int + get() = contributionsSize + + /** + * Open the editor for the language Wikipedia + * + * @param contribution + */ + override fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) { + if (copyWikicode) { + val wikicode = contribution!!.media.wikiCode + Utils.copy("wikicode", wikicode, context) + } + + val url = + languageWikipediaSite!!.mobileUrl() + "/wiki/" + (contribution!!.wikidataPlace + ?.getWikipediaPageTitle()) + Utils.handleWebUrl(context, Uri.parse(url)) + } + + fun getContributionStateAt(position: Int): Int { + return adapter!!.getContributionForPosition(position)!!.state + } + + interface Callback { + fun notifyDataSetChanged() + + fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean) + + // Notify the viewpager that number of items have changed. + fun viewPagerNotifyDataSetChanged() + } + + companion object { + private const val RV_STATE = "rv_scroll_state" + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java deleted file mode 100644 index 100c8be03..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java +++ /dev/null @@ -1,112 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.paging.DataSource; -import androidx.paging.DataSource.Factory; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; -import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener; -import io.reactivex.Scheduler; -import io.reactivex.disposables.CompositeDisposable; -import java.util.Collections; -import javax.inject.Inject; -import javax.inject.Named; -import kotlin.Unit; -import kotlin.jvm.functions.Function0; - -/** - * The presenter class for Contributions - */ -public class ContributionsListPresenter implements UserActionListener { - - private final ContributionBoundaryCallback contributionBoundaryCallback; - private final ContributionsRepository repository; - private final Scheduler ioThreadScheduler; - - private final CompositeDisposable compositeDisposable; - private final ContributionsRemoteDataSource contributionsRemoteDataSource; - - LiveData> contributionList; - - @Inject - ContributionsListPresenter( - final ContributionBoundaryCallback contributionBoundaryCallback, - final ContributionsRemoteDataSource contributionsRemoteDataSource, - final ContributionsRepository repository, - @Named(IO_THREAD) final Scheduler ioThreadScheduler) { - this.contributionBoundaryCallback = contributionBoundaryCallback; - this.repository = repository; - this.ioThreadScheduler = ioThreadScheduler; - this.contributionsRemoteDataSource = contributionsRemoteDataSource; - compositeDisposable = new CompositeDisposable(); - } - - @Override - public void onAttachView(final ContributionsListContract.View view) { - } - - /** - * Setup the paged list. This method sets the configuration for paged list and ties it up with - * the live data object. This method can be tweaked to update the lazy loading behavior of the - * contributions list - */ - void setup(String userName, boolean isSelf) { - final PagedList.Config pagedListConfig = - (new PagedList.Config.Builder()) - .setPrefetchDistance(50) - .setPageSize(10).build(); - Factory factory; - boolean shouldSetBoundaryCallback; - if (!isSelf) { - //We don't want to persist contributions for other user's, therefore - // creating a new DataSource for them - contributionsRemoteDataSource.setUserName(userName); - factory = new Factory() { - @NonNull - @Override - public DataSource create() { - return contributionsRemoteDataSource; - } - }; - shouldSetBoundaryCallback = false; - } else { - contributionBoundaryCallback.setUserName(userName); - shouldSetBoundaryCallback = true; - factory = repository.fetchContributionsWithStates( - Collections.singletonList(Contribution.STATE_COMPLETED)); - } - - LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, - pagedListConfig); - if (shouldSetBoundaryCallback) { - livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback); - } - - contributionList = livePagedListBuilder.build(); - } - - @Override - public void onDetachView() { - compositeDisposable.clear(); - contributionsRemoteDataSource.dispose(); - contributionBoundaryCallback.dispose(); - } - - /** - * It is used to refresh list. - * - * @param swipeRefreshLayout used to stop refresh animation when - * refresh finishes. - */ - @Override - public void refreshList(final SwipeRefreshLayout swipeRefreshLayout) { - contributionBoundaryCallback.refreshList(() -> { - swipeRefreshLayout.setRefreshing(false); - return Unit.INSTANCE; - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt new file mode 100644 index 000000000..1421c1757 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt @@ -0,0 +1,91 @@ +package fr.free.nrw.commons.contributions + +import androidx.lifecycle.LiveData +import androidx.paging.DataSource +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import fr.free.nrw.commons.di.CommonsApplicationModule +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import javax.inject.Inject +import javax.inject.Named + +/** + * The presenter class for Contributions + */ +class ContributionsListPresenter @Inject internal constructor( + private val contributionBoundaryCallback: ContributionBoundaryCallback, + private val contributionsRemoteDataSource: ContributionsRemoteDataSource, + private val repository: ContributionsRepository, + @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler +) : ContributionsListContract.UserActionListener { + private val compositeDisposable = CompositeDisposable() + + var contributionList: LiveData>? = null + + override fun onAttachView(view: ContributionsListContract.View) { + } + + /** + * Setup the paged list. This method sets the configuration for paged list and ties it up with + * the live data object. This method can be tweaked to update the lazy loading behavior of the + * contributions list + */ + fun setup(userName: String?, isSelf: Boolean) { + val pagedListConfig = + (PagedList.Config.Builder()) + .setPrefetchDistance(50) + .setPageSize(10).build() + val factory: DataSource.Factory + val shouldSetBoundaryCallback: Boolean + if (!isSelf) { + //We don't want to persist contributions for other user's, therefore + // creating a new DataSource for them + contributionsRemoteDataSource.userName = userName + factory = object : DataSource.Factory() { + override fun create(): DataSource { + return contributionsRemoteDataSource + } + } + shouldSetBoundaryCallback = false + } else { + contributionBoundaryCallback.userName = userName + shouldSetBoundaryCallback = true + factory = repository.fetchContributionsWithStates( + listOf(Contribution.STATE_COMPLETED) + ) + } + + val livePagedListBuilder: LivePagedListBuilder = LivePagedListBuilder( + factory, + pagedListConfig + ) + if (shouldSetBoundaryCallback) { + livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback) + } + + contributionList = livePagedListBuilder.build() + } + + override fun onDetachView() { + compositeDisposable.clear() + contributionsRemoteDataSource.dispose() + contributionBoundaryCallback.dispose() + } + + /** + * It is used to refresh list. + * + * @param swipeRefreshLayout used to stop refresh animation when + * refresh finishes. + */ + override fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?) { + contributionBoundaryCallback.refreshList { + if (swipeRefreshLayout != null) { + swipeRefreshLayout.isRefreshing = false + } + Unit + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java deleted file mode 100644 index 77dcd5df9..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java +++ /dev/null @@ -1,131 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import androidx.paging.DataSource.Factory; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import io.reactivex.Completable; -import io.reactivex.Single; -import java.util.ArrayList; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; - -/** - * The LocalDataSource class for Contributions - */ -class ContributionsLocalDataSource { - - private final ContributionDao contributionDao; - private final JsonKvStore defaultKVStore; - - @Inject - public ContributionsLocalDataSource( - @Named("default_preferences") final JsonKvStore defaultKVStore, - final ContributionDao contributionDao) { - this.defaultKVStore = defaultKVStore; - this.contributionDao = contributionDao; - } - - /** - * Fetch default number of contributions to be show, based on user preferences - */ - public String getString(final String key) { - return defaultKVStore.getString(key); - } - - /** - * Fetch default number of contributions to be show, based on user preferences - */ - public long getLong(final String key) { - return defaultKVStore.getLong(key); - } - - /** - * Get contribution object from cursor - * - * @param uri - * @return - */ - public Contribution getContributionWithFileName(final String uri) { - final List contributionWithUri = contributionDao.getContributionWithTitle( - uri); - if (!contributionWithUri.isEmpty()) { - return contributionWithUri.get(0); - } - return null; - } - - /** - * Remove a contribution from the contributions table - * - * @param contribution - * @return - */ - public Completable deleteContribution(final Contribution contribution) { - return contributionDao.delete(contribution); - } - - /** - * Deletes contributions with specific states. - * - * @param states The states of the contributions to delete. - * @return A Completable indicating the result of the operation. - */ - public Completable deleteContributionsWithStates(List states) { - return contributionDao.deleteContributionsWithStates(states); - } - - public Factory getContributions() { - return contributionDao.fetchContributions(); - } - - /** - * Fetches contributions with specific states. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states. - */ - public Factory getContributionsWithStates(List states) { - return contributionDao.getContributions(states); - } - - /** - * Fetches contributions with specific states sorted by the date the upload started. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states sorted by - * date upload started. - */ - public Factory getContributionsWithStatesSortedByDateUploadStarted( - List states) { - return contributionDao.getContributionsSortedByDateUploadStarted(states); - } - - public Single> saveContributions(final List contributions) { - final List contributionList = new ArrayList<>(); - for (final Contribution contribution : contributions) { - final Contribution oldContribution = contributionDao.getContribution( - contribution.getPageId()); - if (oldContribution != null) { - contribution.setWikidataPlace(oldContribution.getWikidataPlace()); - } - contributionList.add(contribution); - } - return contributionDao.save(contributionList); - } - - public Completable saveContributions(Contribution contribution) { - return contributionDao.save(contribution); - } - - public void set(final String key, final long value) { - defaultKVStore.putLong(key, value); - } - - public Completable updateContribution(final Contribution contribution) { - return contributionDao.update(contribution); - } - - public Completable updateContributionsWithStates(List states, int newState) { - return contributionDao.updateContributionsWithStates(states, newState); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt new file mode 100644 index 000000000..a35cc15db --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.DataSource +import fr.free.nrw.commons.kvstore.JsonKvStore +import io.reactivex.Completable +import io.reactivex.Single +import javax.inject.Inject +import javax.inject.Named + +/** + * The LocalDataSource class for Contributions + */ +class ContributionsLocalDataSource @Inject constructor( + @param:Named("default_preferences") private val defaultKVStore: JsonKvStore, + private val contributionDao: ContributionDao +) { + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getString(key: String): String? { + return defaultKVStore.getString(key) + } + + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getLong(key: String): Long { + return defaultKVStore.getLong(key) + } + + /** + * Get contribution object from cursor + * + * @param uri + * @return + */ + fun getContributionWithFileName(uri: String): Contribution { + val contributionWithUri = contributionDao.getContributionWithTitle(uri) + if (contributionWithUri.isNotEmpty()) { + return contributionWithUri[0] + } + throw IllegalArgumentException("Contribution not found for URI: $uri") + } + + /** + * Remove a contribution from the contributions table + * + * @param contribution + * @return + */ + fun deleteContribution(contribution: Contribution): Completable { + return contributionDao.delete(contribution) + } + + /** + * Deletes contributions with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsWithStates(states: List): Completable { + return contributionDao.deleteContributionsWithStates(states) + } + + fun getContributions(): DataSource.Factory { + return contributionDao.fetchContributions() + } + + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + fun getContributionsWithStates(states: List): DataSource.Factory { + return contributionDao.getContributions(states) + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + fun getContributionsWithStatesSortedByDateUploadStarted( + states: List + ): DataSource.Factory { + return contributionDao.getContributionsSortedByDateUploadStarted(states) + } + + fun saveContributions(contributions: List): Single> { + val contributionList: MutableList = ArrayList() + for (contribution in contributions) { + val oldContribution = contributionDao.getContribution( + contribution.pageId + ) + if (oldContribution != null) { + contribution.wikidataPlace = oldContribution.wikidataPlace + } + contributionList.add(contribution) + } + return contributionDao.save(contributionList) + } + + fun saveContributions(contribution: Contribution): Completable { + return contributionDao.save(contribution) + } + + fun set(key: String, value: Long) { + defaultKVStore.putLong(key, value) + } + + fun updateContribution(contribution: Contribution): Completable { + return contributionDao.update(contribution) + } + + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return contributionDao.updateContributionsWithStates(states, newState) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.java deleted file mode 100644 index 798b161eb..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.java +++ /dev/null @@ -1,15 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import dagger.Binds; -import dagger.Module; - -/** - * The Dagger Module for contributions related presenters and (some other objects maybe in future) - */ -@Module -public abstract class ContributionsModule { - - @Binds - public abstract ContributionsContract.UserActionListener bindsContibutionsPresenter( - ContributionsPresenter presenter); -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt new file mode 100644 index 000000000..0e27dbade --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.contributions + +import dagger.Binds +import dagger.Module + +/** + * The Dagger Module for contributions-related presenters and other dependencies + */ +@Module +abstract class ContributionsModule { + + @Binds + abstract fun bindsContributionsPresenter( + presenter: ContributionsPresenter? + ): ContributionsContract.UserActionListener? +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java deleted file mode 100644 index 4d05711f3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ /dev/null @@ -1,97 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD; -import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; - -import androidx.work.ExistingWorkPolicy; -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener; -import fr.free.nrw.commons.di.CommonsApplicationModule; -import fr.free.nrw.commons.repository.UploadRepository; -import fr.free.nrw.commons.upload.worker.WorkRequestHelper; -import io.reactivex.Scheduler; -import io.reactivex.disposables.CompositeDisposable; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -/** - * The presenter class for Contributions - */ -public class ContributionsPresenter implements UserActionListener { - - private final ContributionsRepository contributionsRepository; - private final UploadRepository uploadRepository; - private final Scheduler ioThreadScheduler; - private CompositeDisposable compositeDisposable; - private ContributionsContract.View view; - - @Inject - MediaDataExtractor mediaDataExtractor; - - @Inject - ContributionsPresenter(ContributionsRepository repository, - UploadRepository uploadRepository, - @Named(IO_THREAD) Scheduler ioThreadScheduler) { - this.contributionsRepository = repository; - this.uploadRepository = uploadRepository; - this.ioThreadScheduler = ioThreadScheduler; - } - - @Override - public void onAttachView(ContributionsContract.View view) { - this.view = view; - compositeDisposable = new CompositeDisposable(); - } - - @Override - public void onDetachView() { - this.view = null; - compositeDisposable.clear(); - } - - @Override - public Contribution getContributionsWithTitle(String title) { - return contributionsRepository.getContributionWithFileName(title); - } - - /** - * Checks if a contribution is a duplicate and restarts the contribution process if it is not. - * - * @param contribution The contribution to check and potentially restart. - */ - public void checkDuplicateImageAndRestartContribution(Contribution contribution) { - compositeDisposable.add(uploadRepository - .checkDuplicateImage( - contribution.getContentUri(), - contribution.getLocalUri() - ) - .subscribeOn(ioThreadScheduler) - .subscribe(imageCheckResult -> { - if (imageCheckResult == IMAGE_OK) { - contribution.setState(Contribution.STATE_QUEUED); - saveContribution(contribution); - } else { - Timber.e("Contribution already exists"); - compositeDisposable.add(contributionsRepository - .deleteContributionFromDB(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe()); - } - })); - } - - /** - * Update the contribution's state in the databse, upon completion, trigger the workmanager to - * process this contribution - * - * @param contribution - */ - public void saveContribution(Contribution contribution) { - compositeDisposable.add(contributionsRepository - .save(contribution) - .subscribeOn(ioThreadScheduler) - .subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest( - view.getContext().getApplicationContext(), ExistingWorkPolicy.KEEP))); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt new file mode 100644 index 000000000..617051e52 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt @@ -0,0 +1,88 @@ +package fr.free.nrw.commons.contributions + +import androidx.work.ExistingWorkPolicy +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.di.CommonsApplicationModule +import fr.free.nrw.commons.repository.UploadRepository +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ImageUtils +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Named + +/** + * The presenter class for Contributions + */ +class ContributionsPresenter @Inject internal constructor( + private val contributionsRepository: ContributionsRepository, + private val uploadRepository: UploadRepository, + @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler +) : ContributionsContract.UserActionListener { + private var compositeDisposable: CompositeDisposable? = null + private var view: ContributionsContract.View? = null + + @JvmField + @Inject + var mediaDataExtractor: MediaDataExtractor? = null + + override fun onAttachView(view: ContributionsContract.View) { + this.view = view + compositeDisposable = CompositeDisposable() + } + + override fun onDetachView() { + this.view = null + compositeDisposable!!.clear() + } + + override fun getContributionsWithTitle(title: String): Contribution { + return contributionsRepository.getContributionWithFileName(title) + } + + /** + * Checks if a contribution is a duplicate and restarts the contribution process if it is not. + * + * @param contribution The contribution to check and potentially restart. + */ + fun checkDuplicateImageAndRestartContribution(contribution: Contribution) { + compositeDisposable!!.add( + uploadRepository + .checkDuplicateImage( + contribution.contentUri, + contribution.localUri) + .subscribeOn(ioThreadScheduler) + .subscribe { imageCheckResult: Int -> + if (imageCheckResult == ImageUtils.IMAGE_OK) { + contribution.state = Contribution.STATE_QUEUED + saveContribution(contribution) + } else { + Timber.e("Contribution already exists") + compositeDisposable!!.add( + contributionsRepository + .deleteContributionFromDB(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe() + ) + } + }) + } + + /** + * Update the contribution's state in the databse, upon completion, trigger the workmanager to + * process this contribution + * + * @param contribution + */ + fun saveContribution(contribution: Contribution) { + compositeDisposable!!.add(contributionsRepository + .save(contribution) + .subscribeOn(ioThreadScheduler) + .subscribe { + makeOneTimeWorkRequest( + view!!.getContext().applicationContext, ExistingWorkPolicy.KEEP + ) + }) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt new file mode 100644 index 000000000..67e8f50b5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt @@ -0,0 +1,28 @@ +package fr.free.nrw.commons.contributions + +import dagger.Module +import dagger.Provides +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.wikidata.model.WikiSite +import javax.inject.Named + +/** + * The Dagger Module for contributions-related providers + */ +@Module +class ContributionsProvidesModule { + + @Provides + fun providesApplicationKvStore( + @Named("default_preferences") kvStore: JsonKvStore + ): JsonKvStore { + return kvStore + } + + @Provides + fun providesLanguageWikipediaSite( + @Named("language-wikipedia-wikisite") languageWikipediaSite: WikiSite + ): WikiSite { + return languageWikipediaSite + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java deleted file mode 100644 index 3808eba8e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java +++ /dev/null @@ -1,112 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import androidx.paging.DataSource.Factory; -import io.reactivex.Completable; -import java.util.List; - -import javax.inject.Inject; - -import io.reactivex.Single; - -/** - * The repository class for contributions - */ -public class ContributionsRepository { - - private ContributionsLocalDataSource localDataSource; - - @Inject - public ContributionsRepository(ContributionsLocalDataSource localDataSource) { - this.localDataSource = localDataSource; - } - - /** - * Fetch default number of contributions to be show, based on user preferences - */ - public String getString(String key) { - return localDataSource.getString(key); - } - - /** - * Deletes a failed upload from DB - * - * @param contribution - * @return - */ - public Completable deleteContributionFromDB(Contribution contribution) { - return localDataSource.deleteContribution(contribution); - } - - /** - * Deletes contributions from the database with specific states. - * - * @param states The states of the contributions to delete. - * @return A Completable indicating the result of the operation. - */ - public Completable deleteContributionsFromDBWithStates(List states) { - return localDataSource.deleteContributionsWithStates(states); - } - - /** - * Get contribution object with title - * - * @param fileName - * @return - */ - public Contribution getContributionWithFileName(String fileName) { - return localDataSource.getContributionWithFileName(fileName); - } - - public Factory fetchContributions() { - return localDataSource.getContributions(); - } - - /** - * Fetches contributions with specific states. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states. - */ - public Factory fetchContributionsWithStates(List states) { - return localDataSource.getContributionsWithStates(states); - } - - /** - * Fetches contributions with specific states sorted by the date the upload started. - * - * @param states The states of the contributions to fetch. - * @return A DataSource factory for paginated contributions with the specified states sorted by - * date upload started. - */ - public Factory fetchContributionsWithStatesSortedByDateUploadStarted( - List states) { - return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states); - } - - public Single> save(List contributions) { - return localDataSource.saveContributions(contributions); - } - - public Completable save(Contribution contributions) { - return localDataSource.saveContributions(contributions); - } - - public void set(String key, long value) { - localDataSource.set(key, value); - } - - public Completable updateContribution(Contribution contribution) { - return localDataSource.updateContribution(contribution); - } - - /** - * Updates the state of contributions with specific states. - * - * @param states The current states of the contributions to update. - * @param newState The new state to set. - * @return A Completable indicating the result of the operation. - */ - public Completable updateContributionsWithStates(List states, int newState) { - return localDataSource.updateContributionsWithStates(states, newState); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt new file mode 100644 index 000000000..462dbfc7d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt @@ -0,0 +1,102 @@ +package fr.free.nrw.commons.contributions + +import androidx.paging.DataSource +import io.reactivex.Completable +import io.reactivex.Single +import javax.inject.Inject + +/** + * The repository class for contributions + */ +class ContributionsRepository @Inject constructor(private val localDataSource: ContributionsLocalDataSource) { + /** + * Fetch default number of contributions to be show, based on user preferences + */ + fun getString(key: String): String? { + return localDataSource.getString(key) + } + + /** + * Deletes a failed upload from DB + * + * @param contribution + * @return + */ + fun deleteContributionFromDB(contribution: Contribution): Completable { + return localDataSource.deleteContribution(contribution) + } + + /** + * Deletes contributions from the database with specific states. + * + * @param states The states of the contributions to delete. + * @return A Completable indicating the result of the operation. + */ + fun deleteContributionsFromDBWithStates(states: List): Completable { + return localDataSource.deleteContributionsWithStates(states) + } + + /** + * Get contribution object with title + * + * @param fileName + * @return + */ + fun getContributionWithFileName(fileName: String): Contribution { + return localDataSource.getContributionWithFileName(fileName) + } + + fun fetchContributions(): DataSource.Factory { + return localDataSource.getContributions() + } + + /** + * Fetches contributions with specific states. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states. + */ + fun fetchContributionsWithStates(states: List): DataSource.Factory { + return localDataSource.getContributionsWithStates(states) + } + + /** + * Fetches contributions with specific states sorted by the date the upload started. + * + * @param states The states of the contributions to fetch. + * @return A DataSource factory for paginated contributions with the specified states sorted by + * date upload started. + */ + fun fetchContributionsWithStatesSortedByDateUploadStarted( + states: List + ): DataSource.Factory { + return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states) + } + + fun save(contributions: List): Single> { + return localDataSource.saveContributions(contributions) + } + + fun save(contributions: Contribution): Completable { + return localDataSource.saveContributions(contributions) + } + + operator fun set(key: String, value: Long) { + localDataSource.set(key, value) + } + + fun updateContribution(contribution: Contribution): Completable { + return localDataSource.updateContribution(contribution) + } + + /** + * Updates the state of contributions with specific states. + * + * @param states The current states of the contributions to update. + * @param newState The new state to set. + * @return A Completable indicating the result of the operation. + */ + fun updateContributionsWithStates(states: List, newState: Int): Completable { + return localDataSource.updateContributionsWithStates(states, newState) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java deleted file mode 100644 index 047943721..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ /dev/null @@ -1,550 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.work.ExistingWorkPolicy; -import fr.free.nrw.commons.databinding.MainBinding; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.WelcomeActivity; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.bookmarks.BookmarkFragment; -import fr.free.nrw.commons.explore.ExploreFragment; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.media.MediaDetailPagerFragment; -import fr.free.nrw.commons.navtab.MoreBottomSheetFragment; -import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment; -import fr.free.nrw.commons.navtab.NavTab; -import fr.free.nrw.commons.navtab.NavTabLayout; -import fr.free.nrw.commons.navtab.NavTabLoggedOut; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment; -import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback; -import fr.free.nrw.commons.notification.NotificationActivity; -import fr.free.nrw.commons.notification.NotificationController; -import fr.free.nrw.commons.quiz.QuizChecker; -import fr.free.nrw.commons.settings.SettingsFragment; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.upload.UploadProgressActivity; -import fr.free.nrw.commons.upload.worker.WorkRequestHelper; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtilWrapper; -import io.reactivex.Completable; -import io.reactivex.schedulers.Schedulers; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import timber.log.Timber; - -public class MainActivity extends BaseActivity - implements FragmentManager.OnBackStackChangedListener { - - @Inject - SessionManager sessionManager; - @Inject - ContributionController controller; - @Inject - ContributionDao contributionDao; - - private ContributionsFragment contributionsFragment; - private NearbyParentFragment nearbyParentFragment; - private ExploreFragment exploreFragment; - private BookmarkFragment bookmarkFragment; - public ActiveFragment activeFragment; - private MediaDetailPagerFragment mediaDetailPagerFragment; - private NavTabLayout.OnNavigationItemSelectedListener navListener; - - @Inject - public LocationServiceManager locationManager; - @Inject - NotificationController notificationController; - @Inject - QuizChecker quizChecker; - @Inject - @Named("default_preferences") - public - JsonKvStore applicationKvStore; - @Inject - ViewUtilWrapper viewUtilWrapper; - - public Menu menu; - - public MainBinding binding; - - NavTabLayout tabLayout; - - - /** - * Consumers should be simply using this method to use this activity. - * - * @param context A Context of the application package implementing this class. - */ - public static void startYourself(Context context) { - Intent intent = new Intent(context, MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP); - context.startActivity(intent); - } - - @Override - public boolean onSupportNavigateUp() { - if (activeFragment == ActiveFragment.CONTRIBUTIONS) { - if (!contributionsFragment.backButtonClicked()) { - return false; - } - } else { - onBackPressed(); - showTabs(); - } - return true; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - binding = MainBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - setSupportActionBar(binding.toolbarBinding.toolbar); - tabLayout = binding.fragmentMainNavTabLayout; - loadLocale(); - - binding.toolbarBinding.toolbar.setNavigationOnClickListener(view -> { - onSupportNavigateUp(); - }); - /* - "first_edit_depict" is a key for getting information about opening the depiction editor - screen for the first time after opening the app. - - Getting true by the key means the depiction editor screen is opened for the first time - after opening the app. - Getting false by the key means the depiction editor screen is not opened for the first time - after opening the app. - */ - applicationKvStore.putBoolean("first_edit_depict", true); - if (applicationKvStore.getBoolean("login_skipped") == true) { - setTitle(getString(R.string.navigation_item_explore)); - setUpLoggedOutPager(); - } else { - if (applicationKvStore.getBoolean("firstrun", true)) { - applicationKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", false); - applicationKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", false); - } - if (savedInstanceState == null) { - //starting a fresh fragment. - // Open Last opened screen if it is Contributions or Nearby, otherwise Contributions - if (applicationKvStore.getBoolean("last_opened_nearby")) { - setTitle(getString(R.string.nearby_fragment)); - showNearby(); - loadFragment(NearbyParentFragment.newInstance(), false); - } else { - setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(), false); - } - } - setUpPager(); - /** - * Ask the user for media location access just after login - * so that location in the EXIF metadata of the images shared by the user - * is retained on devices running Android 10 or above - */ -// if (VERSION.SDK_INT >= VERSION_CODES.Q) { -// ActivityCompat.requestPermissions(this, -// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0); -// PermissionUtils.checkPermissionsAndPerformAction( -// this, -// () -> {}, -// R.string.media_location_permission_denied, -// R.string.add_location_manually, -// permission.ACCESS_MEDIA_LOCATION); -// } - checkAndResumeStuckUploads(); - } - } - - public void setSelectedItemId(int id) { - binding.fragmentMainNavTabLayout.setSelectedItemId(id); - } - - private void setUpPager() { - binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( - navListener = (item) -> { - if (!item.getTitle().equals(getString(R.string.more))) { - // do not change title for more fragment - setTitle(item.getTitle()); - } - // set last_opened_nearby true if item is nearby screen else set false - applicationKvStore.putBoolean("last_opened_nearby", - item.getTitle().equals(getString(R.string.nearby_fragment))); - final Fragment fragment = NavTab.of(item.getOrder()).newInstance(); - return loadFragment(fragment, true); - }); - } - - private void setUpLoggedOutPager() { - loadFragment(ExploreFragment.newInstance(), false); - binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(item -> { - if (!item.getTitle().equals(getString(R.string.more))) { - // do not change title for more fragment - setTitle(item.getTitle()); - } - Fragment fragment = NavTabLoggedOut.of(item.getOrder()).newInstance(); - return loadFragment(fragment, true); - }); - } - - private boolean loadFragment(Fragment fragment, boolean showBottom) { - //showBottom so that we do not show the bottom tray again when constructing - //from the saved instance state. - - freeUpFragments(); - - if (fragment instanceof ContributionsFragment) { - if (activeFragment == ActiveFragment.CONTRIBUTIONS) { - // scroll to top if already on the Contributions tab - contributionsFragment.scrollToTop(); - return true; - } - contributionsFragment = (ContributionsFragment) fragment; - activeFragment = ActiveFragment.CONTRIBUTIONS; - } else if (fragment instanceof NearbyParentFragment) { - if (activeFragment == ActiveFragment.NEARBY) { // Do nothing if same tab - return true; - } - nearbyParentFragment = (NearbyParentFragment) fragment; - activeFragment = ActiveFragment.NEARBY; - } else if (fragment instanceof ExploreFragment) { - if (activeFragment == ActiveFragment.EXPLORE) { // Do nothing if same tab - return true; - } - exploreFragment = (ExploreFragment) fragment; - activeFragment = ActiveFragment.EXPLORE; - } else if (fragment instanceof BookmarkFragment) { - if (activeFragment == ActiveFragment.BOOKMARK) { // Do nothing if same tab - return true; - } - bookmarkFragment = (BookmarkFragment) fragment; - activeFragment = ActiveFragment.BOOKMARK; - } else if (fragment == null && showBottom) { - if (applicationKvStore.getBoolean("login_skipped") - == true) { // If logged out, more sheet is different - MoreBottomSheetLoggedOutFragment bottomSheet = new MoreBottomSheetLoggedOutFragment(); - bottomSheet.show(getSupportFragmentManager(), - "MoreBottomSheetLoggedOut"); - } else { - MoreBottomSheetFragment bottomSheet = new MoreBottomSheetFragment(); - bottomSheet.show(getSupportFragmentManager(), - "MoreBottomSheet"); - } - } - - if (fragment != null) { - getSupportFragmentManager() - .beginTransaction() - .replace(R.id.fragmentContainer, fragment) - .commit(); - return true; - } - return false; - } - - /** - * loadFragment() overload that supports passing extras to fragments - **/ - private boolean loadFragment(Fragment fragment, boolean showBottom, Bundle args) { - if (fragment != null && args != null) { - fragment.setArguments(args); - } - - return loadFragment(fragment, showBottom); - } - - /** - * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding - * references to cleared fragments. This function frees up all fragment references. - *

- * Called in loadFragment() before doing the actual loading. - **/ - public void freeUpFragments() { - // free all fragments except contributionsFragment because several tests depend on it. - // hence, contributionsFragment is probably still a leak - nearbyParentFragment = null; - exploreFragment = null; - bookmarkFragment = null; - } - - public void hideTabs() { - binding.fragmentMainNavTabLayout.setVisibility(View.GONE); - } - - public void showTabs() { - binding.fragmentMainNavTabLayout.setVisibility(View.VISIBLE); - } - - /** - * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions - * (NUMBER)" - * - * @param uploadCount - */ - public void setNumOfUploads(int uploadCount) { - if (activeFragment == ActiveFragment.CONTRIBUTIONS) { - setTitle(getResources().getString(R.string.contributions_fragment) + " " + ( - !(uploadCount == 0) ? - getResources() - .getQuantityString(R.plurals.contributions_subtitle, - uploadCount, uploadCount) - : getString(R.string.contributions_subtitle_zero))); - } - } - - /** - * Resume the uploads that got stuck because of the app being killed or the device being - * rebooted. - *

- * When the app is terminated or the device is restarted, contributions remain in the - * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, - * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the - * list of uploads that appear as stuck on opening the app again - */ - @SuppressLint("CheckResult") - private void checkAndResumeStuckUploads() { - List stuckUploads = contributionDao.getContribution( - Collections.singletonList(Contribution.STATE_IN_PROGRESS)) - .subscribeOn(Schedulers.io()) - .blockingGet(); - Timber.d("Resuming " + stuckUploads.size() + " uploads..."); - if (!stuckUploads.isEmpty()) { - for (Contribution contribution : stuckUploads) { - contribution.setState(Contribution.STATE_QUEUED); - contribution.setDateUploadStarted(Calendar.getInstance().getTime()); - Completable.fromAction(() -> contributionDao.saveSynchronous(contribution)) - .subscribeOn(Schedulers.io()) - .subscribe(); - } - WorkRequestHelper.Companion.makeOneTimeWorkRequest( - this, ExistingWorkPolicy.APPEND_OR_REPLACE); - } - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - //quizChecker.initQuizCheck(this); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("viewPagerCurrentItem", binding.pager.getCurrentItem()); - outState.putString("activeFragment", activeFragment.name()); - } - - @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - String activeFragmentName = savedInstanceState.getString("activeFragment"); - if (activeFragmentName != null) { - restoreActiveFragment(activeFragmentName); - } - } - - private void restoreActiveFragment(@NonNull String fragmentName) { - if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) { - setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.NEARBY.name())) { - setTitle(getString(R.string.nearby_fragment)); - loadFragment(NearbyParentFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) { - setTitle(getString(R.string.navigation_item_explore)); - loadFragment(ExploreFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) { - setTitle(getString(R.string.bookmarks)); - loadFragment(BookmarkFragment.newInstance(), false); - } - } - - @Override - public void onBackPressed() { - if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { - // Means that contribution fragment is visible - if (!contributionsFragment.backButtonClicked()) {//If this one does not wan't to handle - // the back press, let the activity do so - super.onBackPressed(); - } - } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { - // Means that nearby fragment is visible - /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is - not expanded. So if the back button is pressed, then go back to the Contributions tab */ - if (!nearbyParentFragment.backButtonClicked()) { - getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment) - .commit(); - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { - // Means that explore fragment is visible - if (!exploreFragment.onBackPressed()) { - if (applicationKvStore.getBoolean("login_skipped")) { - super.onBackPressed(); - } else { - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } - } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { - // Means that bookmark fragment is visible - bookmarkFragment.onBackPressed(); - } else { - super.onBackPressed(); - } - } - - @Override - public void onBackStackChanged() { - //initBackButton(); - } - - /** - * Retry all failed uploads as soon as the user returns to the app - */ - @SuppressLint("CheckResult") - private void retryAllFailedUploads() { - contributionDao. - getContribution(Collections.singletonList(Contribution.STATE_FAILED)) - .subscribeOn(Schedulers.io()) - .subscribe(failedUploads -> { - for (Contribution contribution : failedUploads) { - contributionsFragment.retryUpload(contribution); - } - }); - } - - /** - * Handles item selection in the options menu. This method is called when a user interacts with - * the options menu in the Top Bar. - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.upload_tab: - startActivity(new Intent(this, UploadProgressActivity.class)); - return true; - case R.id.notifications: - // Starts notification activity on click to notification icon - NotificationActivity.Companion.startYourself(this, "unread"); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void centerMapToPlace(Place place) { - setSelectedItemId(NavTab.NEARBY.code()); - nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback( - new NearbyParentFragmentInstanceReadyCallback() { - @Override - public void onReady() { - nearbyParentFragment.centerMapToPlace(place); - } - }); - } - - /** - * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks - * the 'Show in Explore' option in the 3-dots menu in Nearby. - * - * @param zoom current zoom of Nearby map - * @param latitude current latitude of Nearby map - * @param longitude current longitude of Nearby map - **/ - public void loadExploreMapFromNearby(double zoom, double latitude, double longitude) { - Bundle bundle = new Bundle(); - bundle.putDouble("prev_zoom", zoom); - bundle.putDouble("prev_latitude", latitude); - bundle.putDouble("prev_longitude", longitude); - - loadFragment(ExploreFragment.newInstance(), false, bundle); - setSelectedItemId(NavTab.EXPLORE.code()); - } - - /** - * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks - * the 'Show in Nearby' option in the 3-dots menu in Explore. - * - * @param zoom current zoom of Explore map - * @param latitude current latitude of Explore map - * @param longitude current longitude of Explore map - **/ - public void loadNearbyMapFromExplore(double zoom, double latitude, double longitude) { - Bundle bundle = new Bundle(); - bundle.putDouble("prev_zoom", zoom); - bundle.putDouble("prev_latitude", latitude); - bundle.putDouble("prev_longitude", longitude); - - loadFragment(NearbyParentFragment.newInstance(), false, bundle); - setSelectedItemId(NavTab.NEARBY.code()); - } - - @Override - protected void onResume() { - super.onResume(); - - if ((applicationKvStore.getBoolean("firstrun", true)) && - (!applicationKvStore.getBoolean("login_skipped"))) { - defaultKvStore.putBoolean("inAppCameraFirstRun", true); - WelcomeActivity.startYourself(this); - } - - retryAllFailedUploads(); - } - - @Override - protected void onDestroy() { - quizChecker.cleanup(); - locationManager.unregisterLocationManager(); - // Remove ourself from hashmap to prevent memory leaks - locationManager = null; - super.onDestroy(); - } - - /** - * Public method to show nearby from the reference of this. - */ - public void showNearby() { - binding.fragmentMainNavTabLayout.setSelectedItemId(NavTab.NEARBY.code()); - } - - public enum ActiveFragment { - CONTRIBUTIONS, - NEARBY, - EXPLORE, - BOOKMARK, - MORE - } - - /** - * Load default language in onCreate from SharedPreferences - */ - private void loadLocale() { - final SharedPreferences preferences = getSharedPreferences("Settings", - Activity.MODE_PRIVATE); - final String language = preferences.getString("language", ""); - final SettingsFragment settingsFragment = new SettingsFragment(); - settingsFragment.setLocale(this, language); - } - - public NavTabLayout.OnNavigationItemSelectedListener getNavListener() { - return navListener; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt new file mode 100644 index 000000000..8d6efd664 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -0,0 +1,567 @@ +package fr.free.nrw.commons.contributions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.work.ExistingWorkPolicy +import com.google.android.material.bottomnavigation.BottomNavigationView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.WelcomeActivity +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.contributions.ContributionsFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.MainBinding +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment +import fr.free.nrw.commons.navtab.NavTab +import fr.free.nrw.commons.navtab.NavTabLayout +import fr.free.nrw.commons.navtab.NavTabLoggedOut +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +import fr.free.nrw.commons.notification.NotificationController +import fr.free.nrw.commons.quiz.QuizChecker +import fr.free.nrw.commons.settings.SettingsFragment +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Completable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + + +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + @JvmField + @Inject + var sessionManager: SessionManager? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var contributionDao: ContributionDao? = null + + private var contributionsFragment: ContributionsFragment? = null + private var nearbyParentFragment: NearbyParentFragment? = null + private var exploreFragment: ExploreFragment? = null + private var bookmarkFragment: BookmarkFragment? = null + @JvmField + var activeFragment: ActiveFragment? = null + private val mediaDetailPagerFragment: MediaDetailPagerFragment? = null + var navListener: BottomNavigationView.OnNavigationItemSelectedListener? = null + private set + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var quizChecker: QuizChecker? = null + + @JvmField + @Inject + @Named("default_preferences") + var applicationKvStore: JsonKvStore? = null + + @JvmField + @Inject + var viewUtilWrapper: ViewUtilWrapper? = null + + var menu: Menu? = null + + @JvmField + var binding: MainBinding? = null + + var tabLayout: NavTabLayout? = null + + + override fun onSupportNavigateUp(): Boolean { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + if (!contributionsFragment!!.backButtonClicked()) { + return false + } + } else { + onBackPressed() + showTabs() + } + return true + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = MainBinding.inflate(layoutInflater) + setContentView(binding!!.root) + setSupportActionBar(binding!!.toolbarBinding.toolbar) + tabLayout = binding!!.fragmentMainNavTabLayout + loadLocale() + + binding!!.toolbarBinding.toolbar.setNavigationOnClickListener { view: View? -> + onSupportNavigateUp() + } + /* +"first_edit_depict" is a key for getting information about opening the depiction editor +screen for the first time after opening the app. + +Getting true by the key means the depiction editor screen is opened for the first time +after opening the app. +Getting false by the key means the depiction editor screen is not opened for the first time +after opening the app. + */ + applicationKvStore!!.putBoolean("first_edit_depict", true) + if (applicationKvStore!!.getBoolean("login_skipped") == true) { + title = getString(R.string.navigation_item_explore) + setUpLoggedOutPager() + } else { + if (applicationKvStore!!.getBoolean("firstrun", true)) { + applicationKvStore!!.putBoolean("hasAlreadyLaunchedBigMultiupload", false) + applicationKvStore!!.putBoolean("hasAlreadyLaunchedCategoriesDialog", false) + } + if (savedInstanceState == null) { + //starting a fresh fragment. + // Open Last opened screen if it is Contributions or Nearby, otherwise Contributions + if (applicationKvStore!!.getBoolean("last_opened_nearby")) { + title = getString(R.string.nearby_fragment) + showNearby() + loadFragment(NearbyParentFragment.newInstance(), false) + } else { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } + } + setUpPager() + /** + * Ask the user for media location access just after login + * so that location in the EXIF metadata of the images shared by the user + * is retained on devices running Android 10 or above + */ +// if (VERSION.SDK_INT >= VERSION_CODES.Q) { +// ActivityCompat.requestPermissions(this, +// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0); +// PermissionUtils.checkPermissionsAndPerformAction( +// this, +// () -> {}, +// R.string.media_location_permission_denied, +// R.string.add_location_manually, +// permission.ACCESS_MEDIA_LOCATION); +// } + checkAndResumeStuckUploads() + } + } + + fun setSelectedItemId(id: Int) { + binding!!.fragmentMainNavTabLayout.selectedItemId = id + } + + private fun setUpPager() { + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( + BottomNavigationView.OnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + // set last_opened_nearby true if item is nearby screen else set false + applicationKvStore!!.putBoolean( + "last_opened_nearby", + item.title == getString(R.string.nearby_fragment) + ) + val fragment = NavTab.of(item.order).newInstance() + loadFragment(fragment, true) + }.also { navListener = it }) + } + + private fun setUpLoggedOutPager() { + loadFragment(ExploreFragment.newInstance(), false) + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + val fragment = + NavTabLoggedOut.of(item.order).newInstance() + loadFragment(fragment, true) + } + } + + private fun loadFragment(fragment: Fragment?, showBottom: Boolean): Boolean { + //showBottom so that we do not show the bottom tray again when constructing + //from the saved instance state. + + freeUpFragments(); + + if (fragment is ContributionsFragment) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + // scroll to top if already on the Contributions tab + contributionsFragment!!.scrollToTop() + return true + } + contributionsFragment = fragment + activeFragment = ActiveFragment.CONTRIBUTIONS + } else if (fragment is NearbyParentFragment) { + if (activeFragment == ActiveFragment.NEARBY) { // Do nothing if same tab + return true + } + nearbyParentFragment = fragment + activeFragment = ActiveFragment.NEARBY + } else if (fragment is ExploreFragment) { + if (activeFragment == ActiveFragment.EXPLORE) { // Do nothing if same tab + return true + } + exploreFragment = fragment + activeFragment = ActiveFragment.EXPLORE + } else if (fragment is BookmarkFragment) { + if (activeFragment == ActiveFragment.BOOKMARK) { // Do nothing if same tab + return true + } + bookmarkFragment = fragment + activeFragment = ActiveFragment.BOOKMARK + } else if (fragment == null && showBottom) { + if (applicationKvStore!!.getBoolean("login_skipped") + == true + ) { // If logged out, more sheet is different + val bottomSheet = MoreBottomSheetLoggedOutFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheetLoggedOut" + ) + } else { + val bottomSheet = MoreBottomSheetFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheet" + ) + } + } + + if (fragment != null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragmentContainer, fragment) + .commit() + return true + } + return false + } + + /** + * loadFragment() overload that supports passing extras to fragments + */ + private fun loadFragment(fragment: Fragment?, showBottom: Boolean, args: Bundle?): Boolean { + if (fragment != null && args != null) { + fragment.arguments = args + } + + return loadFragment(fragment, showBottom) + } + + /** + * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding + * references to cleared fragments. This function frees up all fragment references. + * + * + * Called in loadFragment() before doing the actual loading. + */ + fun freeUpFragments() { + // free all fragments except contributionsFragment because several tests depend on it. + // hence, contributionsFragment is probably still a leak + nearbyParentFragment = null + exploreFragment = null + bookmarkFragment = null + } + + + fun hideTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.GONE + } + + fun showTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.VISIBLE + } + + /** + * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions + * (NUMBER)" + * + * @param uploadCount + */ + fun setNumOfUploads(uploadCount: Int) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + title = + resources.getString(R.string.contributions_fragment) + " " + (if (uploadCount != 0) + resources + .getQuantityString( + R.plurals.contributions_subtitle, + uploadCount, uploadCount + ) + else + getString(R.string.contributions_subtitle_zero)) + } + } + + /** + * Resume the uploads that got stuck because of the app being killed or the device being + * rebooted. + * + * + * When the app is terminated or the device is restarted, contributions remain in the + * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, + * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the + * list of uploads that appear as stuck on opening the app again + */ + @SuppressLint("CheckResult") + private fun checkAndResumeStuckUploads() { + val stuckUploads = contributionDao!!.getContribution( + listOf(Contribution.STATE_IN_PROGRESS) + ) + .subscribeOn(Schedulers.io()) + .blockingGet() + Timber.d("Resuming " + stuckUploads.size + " uploads...") + if (!stuckUploads.isEmpty()) { + for (contribution in stuckUploads) { + contribution.state = Contribution.STATE_QUEUED + contribution.dateUploadStarted = Calendar.getInstance().time + Completable.fromAction { contributionDao!!.saveSynchronous(contribution) } + .subscribeOn(Schedulers.io()) + .subscribe() + } + makeOneTimeWorkRequest( + this, ExistingWorkPolicy.APPEND_OR_REPLACE + ) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + //quizChecker.initQuizCheck(this); + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("viewPagerCurrentItem", binding!!.pager.currentItem) + outState.putString("activeFragment", activeFragment!!.name) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val activeFragmentName = savedInstanceState.getString("activeFragment") + if (activeFragmentName != null) { + restoreActiveFragment(activeFragmentName) + } + } + + private fun restoreActiveFragment(fragmentName: String) { + if (fragmentName == ActiveFragment.CONTRIBUTIONS.name) { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } else if (fragmentName == ActiveFragment.NEARBY.name) { + title = getString(R.string.nearby_fragment) + loadFragment(NearbyParentFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.EXPLORE.name) { + title = getString(R.string.navigation_item_explore) + loadFragment(ExploreFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.BOOKMARK.name) { + title = getString(R.string.bookmarks) + loadFragment(BookmarkFragment.newInstance(), false) + } + } + + override fun onBackPressed() { + if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { + // Means that contribution fragment is visible + if (!contributionsFragment!!.backButtonClicked()) { //If this one does not wan't to handle + // the back press, let the activity do so + super.onBackPressed() + } + } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { + // Means that nearby fragment is visible + /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is + not expanded. So if the back button is pressed, then go back to the Contributions tab */ + if (!nearbyParentFragment!!.backButtonClicked()) { + supportFragmentManager.beginTransaction().remove(nearbyParentFragment!!) + .commit() + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { + // Means that explore fragment is visible + if (!exploreFragment!!.onBackPressed()) { + if (applicationKvStore!!.getBoolean("login_skipped")) { + super.onBackPressed() + } else { + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } + } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { + // Means that bookmark fragment is visible + bookmarkFragment!!.onBackPressed() + } else { + super.onBackPressed() + } + } + + override fun onBackStackChanged() { + //initBackButton(); + } + + /** + * Retry all failed uploads as soon as the user returns to the app + */ + @SuppressLint("CheckResult") + private fun retryAllFailedUploads() { + contributionDao + ?.getContribution(listOf(Contribution.STATE_FAILED)) + ?.subscribeOn(Schedulers.io()) + ?.subscribe { failedUploads -> + failedUploads.forEach { contribution -> + contributionsFragment?.retryUpload(contribution) + } + } + } + + /** + * Handles item selection in the options menu. This method is called when a user interacts with + * the options menu in the Top Bar. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.upload_tab -> { + startActivity(Intent(this, UploadProgressActivity::class.java)) + return true + } + + R.id.notifications -> { + // Starts notification activity on click to notification icon + startYourself(this, "unread") + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun centerMapToPlace(place: Place?) { + setSelectedItemId(NavTab.NEARBY.code()) + nearbyParentFragment!!.setNearbyParentFragmentInstanceReadyCallback { + nearbyParentFragment!!.centerMapToPlace( + place + ) + } + } + + /** + * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks + * the 'Show in Explore' option in the 3-dots menu in Nearby. + * + * @param zoom current zoom of Nearby map + * @param latitude current latitude of Nearby map + * @param longitude current longitude of Nearby map + */ + fun loadExploreMapFromNearby(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(ExploreFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.EXPLORE.code()) + } + + /** + * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks + * the 'Show in Nearby' option in the 3-dots menu in Explore. + * + * @param zoom current zoom of Explore map + * @param latitude current latitude of Explore map + * @param longitude current longitude of Explore map + */ + fun loadNearbyMapFromExplore(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(NearbyParentFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.NEARBY.code()) + } + + override fun onResume() { + super.onResume() + + if ((applicationKvStore!!.getBoolean("firstrun", true)) && + (!applicationKvStore!!.getBoolean("login_skipped")) + ) { + defaultKvStore.putBoolean("inAppCameraFirstRun", true) + WelcomeActivity.startYourself(this) + } + + retryAllFailedUploads() + } + + override fun onDestroy() { + quizChecker!!.cleanup() + locationManager!!.unregisterLocationManager() + // Remove ourself from hashmap to prevent memory leaks + locationManager = null + super.onDestroy() + } + + /** + * Public method to show nearby from the reference of this. + */ + fun showNearby() { + binding!!.fragmentMainNavTabLayout.selectedItemId = NavTab.NEARBY.code() + } + + enum class ActiveFragment { + CONTRIBUTIONS, + NEARBY, + EXPLORE, + BOOKMARK, + MORE + } + + /** + * Load default language in onCreate from SharedPreferences + */ + private fun loadLocale() { + val preferences = getSharedPreferences( + "Settings", + MODE_PRIVATE + ) + val language = preferences.getString("language", "")!! + val settingsFragment = SettingsFragment() + settingsFragment.setLocale(this, language) + } + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * + * @param context A Context of the application package implementing this class. + */ + fun startYourself(context: Context) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java deleted file mode 100644 index 0f18c300b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java +++ /dev/null @@ -1,126 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.WallpaperManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Build; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.R; -import timber.log.Timber; - -public class SetWallpaperWorker extends Worker { - - private static final String NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel"; - private static final int NOTIFICATION_ID = 1; - - public SetWallpaperWorker(@NonNull Context context, @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - Context context = getApplicationContext(); - createNotificationChannel(context); - showProgressNotification(context); - - String imageUrl = getInputData().getString("imageUrl"); - if (imageUrl == null) { - return Result.failure(); - } - - ImageRequest imageRequest = ImageRequestBuilder - .newBuilderWithSource(Uri.parse(imageUrl)) - .build(); - - ImagePipeline imagePipeline = Fresco.getImagePipeline(); - final DataSource> - dataSource = imagePipeline.fetchDecodedImage(imageRequest, context); - - dataSource.subscribe(new BaseBitmapDataSubscriber() { - @Override - public void onNewResultImpl(@Nullable Bitmap bitmap) { - if (dataSource.isFinished() && bitmap != null) { - Timber.d("Bitmap loaded from url %s", imageUrl.toString()); - setWallpaper(context, Bitmap.createBitmap(bitmap)); - dataSource.close(); - } - } - - @Override - public void onFailureImpl(DataSource dataSource) { - Timber.d("Error getting bitmap from image url %s", imageUrl.toString()); - showNotification(context, "Setting Wallpaper Failed", "Failed to download image."); - if (dataSource != null) { - dataSource.close(); - } - } - }, CallerThreadExecutor.getInstance()); - - return Result.success(); - } - - private void setWallpaper(Context context, Bitmap bitmap) { - WallpaperManager wallpaperManager = WallpaperManager.getInstance(context); - - try { - wallpaperManager.setBitmap(bitmap); - showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully."); - - } catch (Exception e) { - Timber.e(e, "Error setting wallpaper"); - showNotification(context, "Setting Wallpaper Failed", " "+e.getLocalizedMessage()); - } - } - - private void showProgressNotification(Context context) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle("Setting Wallpaper") - .setContentText("Please wait...") - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(true) - .setProgress(0, 0, true); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void showNotification(Context context, String title, String content) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle(title) - .setContentText(content) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(false); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void createNotificationChannel(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = "Wallpaper Setting"; - String description = "Notifications for wallpaper setting progress"; - int importance = NotificationManager.IMPORTANCE_HIGH; - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance); - channel.setDescription(description); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt new file mode 100644 index 000000000..06c31fede --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -0,0 +1,113 @@ +package fr.free.nrw.commons.contributions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.WallpaperManager +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.R +import timber.log.Timber + +class SetWallpaperWorker(context: Context, params: WorkerParameters) : + Worker(context, params) { + override fun doWork(): Result { + val context = applicationContext + createNotificationChannel(context) + showProgressNotification(context) + + val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() + + val imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(imageUrl)) + .build() + + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + public override fun onNewResultImpl(bitmap: Bitmap?) { + if (dataSource.isFinished && bitmap != null) { + Timber.d("Bitmap loaded from url %s", imageUrl.toString()) + setWallpaper(context, Bitmap.createBitmap(bitmap)) + dataSource.close() + } + } + + override fun onFailureImpl(dataSource: DataSource>?) { + Timber.d("Error getting bitmap from image url %s", imageUrl.toString()) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") + dataSource?.close() + } + }, CallerThreadExecutor.getInstance()) + + return Result.success() + } + + private fun setWallpaper(context: Context, bitmap: Bitmap) { + val wallpaperManager = WallpaperManager.getInstance(context) + + try { + wallpaperManager.setBitmap(bitmap) + showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.") + } catch (e: Exception) { + Timber.e(e, "Error setting wallpaper") + showNotification(context, "Setting Wallpaper Failed", " " + e.localizedMessage) + } + } + + private fun showProgressNotification(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle("Setting Wallpaper") + .setContentText("Please wait...") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setProgress(0, 0, true) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun showNotification(context: Context, title: String, content: String) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Wallpaper Setting" + val description = "Notifications for wallpaper setting progress" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + companion object { + private const val NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java deleted file mode 100644 index 898a36a99..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.ViewPager; - -public class UnswipableViewPager extends ViewPager{ - public UnswipableViewPager(@NonNull Context context) { - super(context); - } - - public UnswipableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent event) { - // Unswipable - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - // Unswipable - return false; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt new file mode 100644 index 000000000..dd6ae661a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class UnswipableViewPager : ViewPager { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index 86cda2cf3..f16a48b4c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -43,7 +43,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { /** * Callback for handling confirm button clicked */ - interface Callback { + fun interface Callback { fun onConfirmClicked( contribution: Contribution?, copyWikicode: Boolean, diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt index 94319060b..9e569982f 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt @@ -9,6 +9,7 @@ import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.activity.SingleWebViewActivity import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.contributions.ContributionsModule +import fr.free.nrw.commons.contributions.ContributionsProvidesModule import fr.free.nrw.commons.explore.SearchModule import fr.free.nrw.commons.explore.categories.CategoriesModule import fr.free.nrw.commons.explore.depictions.DepictionModule @@ -40,6 +41,7 @@ import javax.inject.Singleton ContentProviderBuilderModule::class, UploadModule::class, ContributionsModule::class, + ContributionsProvidesModule::class, SearchModule::class, DepictionModule::class, CategoriesModule::class diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt index 8204d4415..5468cfa10 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt @@ -15,8 +15,8 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje @Inject @JvmField var childFragmentInjector: DispatchingAndroidInjector? = null - @JvmField - protected var compositeDisposable: CompositeDisposable = CompositeDisposable() + // Removed @JvmField to allow overriding + protected open var compositeDisposable: CompositeDisposable = CompositeDisposable() override fun onAttach(context: Context) { inject() @@ -63,4 +63,9 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje return getInstance(activity.applicationContext) } + + // Ensure getContext() returns a non-null Context + override fun getContext(): Context { + return super.getContext() ?: throw IllegalStateException("Context is null") + } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index e64b96190..1b1659182 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -467,7 +467,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(getLastMapFocus(), currentLatLng, false); } - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(explorePlacesInfo -> { diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt index 2ed573740..acf072f02 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -426,7 +426,7 @@ object FilePicker : Constants { fun onCanceled(source: ImageSource, type: Int) } - interface HandleActivityResult { + fun interface HandleActivityResult { fun onHandleActivityResult(callbacks: Callbacks) } } \ No newline at end of file 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 d714b094a..40c9785db 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 @@ -493,7 +493,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C val contributionsFragment: ContributionsFragment? = this.getContributionsFragmentParent() if (contributionsFragment?.binding != null) { - contributionsFragment.binding.cardViewNearby.visibility = View.GONE + contributionsFragment.binding!!.cardViewNearby.visibility = View.GONE } // detail provider is null when fragment is shown in review activity diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt index 8d5298cac..73d030ed0 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt @@ -31,8 +31,8 @@ class NavTabLayout : BottomNavigationView { private fun setTabViews() { val isLoginSkipped = (context as MainActivity) - .applicationKvStore.getBoolean("login_skipped") - if (isLoginSkipped) { + .applicationKvStore?.getBoolean("login_skipped") + if (isLoginSkipped == true) { for (i in 0 until NavTabLoggedOut.size()) { val navTab = NavTabLoggedOut.of(i) menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()) diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index 95f19f699..2b64f0e37 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -742,7 +742,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment public void onPause() { super.onPause(); binding.map.onPause(); - compositeDisposable.clear(); + getCompositeDisposable().clear(); presenter.detachView(); registerUnregisterLocationListener(true); try { @@ -857,7 +857,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment 0.75); binding.nearbyFilterList.searchListView.setAdapter(nearbyFilterSearchRecyclerViewAdapter); LayoutUtils.setLayoutHeightAlignedToWidth(1.25, binding.nearbyFilterList.getRoot()); - compositeDisposable.add( + getCompositeDisposable().add( RxSearchView.queryTextChanges(binding.nearbyFilter.searchViewLayout.searchView) .takeUntil(RxView.detaches(binding.nearbyFilter.searchViewLayout.searchView)) .debounce(500, TimeUnit.MILLISECONDS) @@ -1234,7 +1234,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment */ private void emptyCache() { // reload the map once the cache is cleared - compositeDisposable.add( + getCompositeDisposable().add( placesRepository.clearCache() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -1269,7 +1269,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable savePlacesObservable = Observable .fromCallable(() -> nearbyController .getPlacesAsKML(getMapFocus())); - compositeDisposable.add(savePlacesObservable + getCompositeDisposable().add(savePlacesObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(kmlString -> { @@ -1303,7 +1303,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable savePlacesObservable = Observable .fromCallable(() -> nearbyController .getPlacesAsGPX(getMapFocus())); - compositeDisposable.add(savePlacesObservable + getCompositeDisposable().add(savePlacesObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(gpxString -> { @@ -1405,7 +1405,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable> getPlaceObservable = Observable .fromCallable(() -> nearbyController .getPlaces(List.of(place))); - compositeDisposable.add(getPlaceObservable + getCompositeDisposable().add(getPlaceObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(placeList -> { @@ -1449,7 +1449,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment searchLatLng, false, true, Utils.isMonumentsEnabled(new Date()), customQuery)); - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(nearbyPlacesInfo -> { @@ -1486,7 +1486,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment searchLatLng, false, true, Utils.isMonumentsEnabled(new Date()), customQuery)); - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(nearbyPlacesInfo -> { @@ -1518,7 +1518,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } public void savePlaceToDatabase(Place place) { - compositeDisposable.add(placesRepository + getCompositeDisposable().add(placesRepository .save(place) .subscribeOn(Schedulers.io()) .subscribe()); @@ -1531,7 +1531,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment @Override public void stopQuery() { stopQuery = true; - compositeDisposable.clear(); + getCompositeDisposable().clear(); } /** 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 a27993f9e..443a112dd 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 @@ -204,7 +204,7 @@ class UploadRepository @Inject constructor( * @param filePath file to be checked * @return IMAGE_DUPLICATE or IMAGE_OK */ - fun checkDuplicateImage(originalFilePath: Uri, modifiedFilePath: Uri): Single { + fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single { return uploadModel.checkDuplicateImage(originalFilePath, modifiedFilePath) } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt index 0b2c47ddc..5eeed2bc6 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt @@ -28,8 +28,7 @@ import javax.inject.Named /** * The presenter class for PendingUploadsFragment and FailedUploadsFragment - */ -class PendingUploadsPresenter @Inject internal constructor( + */ class PendingUploadsPresenter @Inject internal constructor( private val contributionBoundaryCallback: ContributionBoundaryCallback, private val contributionsRemoteDataSource: ContributionsRemoteDataSource, private val contributionsRepository: ContributionsRepository, @@ -89,12 +88,16 @@ class PendingUploadsPresenter @Inject internal constructor( * @param context The context in which the operation is being performed. */ override fun deleteUpload(contribution: Contribution?, context: Context?) { - compositeDisposable.add( + contribution?.let { contributionsRepository - .deleteContributionFromDB(contribution) + .deleteContributionFromDB(it) .subscribeOn(ioThreadScheduler) .subscribe() - ) + }?.let { + compositeDisposable.add( + it + ) + } } /** 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 index 56ad9dd84..020284934 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt @@ -679,7 +679,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } private fun receiveExternalSharedItems() { - uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent) + uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent).toMutableList() } private fun receiveInternalSharedItems() { 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 80037a028..7a92cf6c5 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 @@ -1,5 +1,6 @@ package fr.free.nrw.commons.upload.categories +import android.annotation.SuppressLint import android.app.Activity import android.app.ProgressDialog import android.content.Context @@ -89,6 +90,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View { } } + @SuppressLint("StringFormatMatches") private fun init() { if (binding == null) { return @@ -372,8 +374,9 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View { (requireActivity() as AppCompatActivity).supportActionBar?.hide() + if (parentFragment?.parentFragment?.parentFragment is ContributionsFragment) { - ((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = View.GONE + ((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding?.cardViewNearby?.visibility = View.GONE } } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index 782b15c0c..75db6ffc0 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -149,7 +149,7 @@ class UploadWorker( currentNotification.build(), ) contribution!!.transferred = transferred - contributionDao.update(contribution).blockingAwait() + contributionDao.update(contribution!!).blockingAwait() } open fun onChunkUploaded( diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt index 397e03070..0171f2693 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt @@ -115,11 +115,10 @@ class ContributionViewHolderUnitTests { @Throws(Exception::class) fun testDisplayWikipediaButton() { Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - ContributionViewHolder::class.java.getDeclaredMethod( - "displayWikipediaButton", - Boolean::class.javaObjectType, - ) + val method: Method = ContributionViewHolder::class.java.getDeclaredMethod( + "displayWikipediaButton", + Boolean::class.javaPrimitiveType + ) method.isAccessible = true method.invoke(contributionViewHolder, false) } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt index db2475f63..54228bc13 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt @@ -89,7 +89,7 @@ class ContributionsListFragmentUnitTests { Shadows.shadowOf(Looper.getMainLooper()).idle() fragment.rvContributionsList = mock() fragment.scrollToTop() - verify(fragment.rvContributionsList).smoothScrollToPosition(0) + verify(fragment.rvContributionsList)?.smoothScrollToPosition(0) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt index 780322603..b3750c5f3 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt @@ -448,7 +448,7 @@ class MainActivityUnitTests { fun testOnSetUpPagerNearBy() { val item = Mockito.mock(MenuItem::class.java) `when`(item.title).thenReturn(activity.getString(R.string.nearby_fragment)) - activity.navListener.onNavigationItemSelected(item) + activity.navListener?.onNavigationItemSelected(item) verify(item, Mockito.times(3)).title verify(applicationKvStore, Mockito.times(1)) .putBoolean("last_opened_nearby", true) @@ -459,7 +459,7 @@ class MainActivityUnitTests { fun testOnSetUpPagerOtherThanNearBy() { val item = Mockito.mock(MenuItem::class.java) `when`(item.title).thenReturn(activity.getString(R.string.bookmarks)) - activity.navListener.onNavigationItemSelected(item) + activity.navListener?.onNavigationItemSelected(item) verify(item, Mockito.times(3)).title verify(applicationKvStore, Mockito.times(1)) .putBoolean("last_opened_nearby", false)