Migrated contributions folder Files from java to kotlin (#6176)

* 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<T>() 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<View>(...)) 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 <nicolas.raoul@gmail.com>

* 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 <nicolas.raoul@gmail.com>

* Localisation updates from https://translatewiki.net.

* enhance spammy category filter (#6167)

Signed-off-by: parneet-guraya <gurayaparneet@gmail.com>

* 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 <gurayaparneet@gmail.com>

* fix tests

Signed-off-by: parneet-guraya <gurayaparneet@gmail.com>

---------

Signed-off-by: parneet-guraya <gurayaparneet@gmail.com>

* Add multiline input for caption and description (#6173)

* allow multiple lines for description/caption

* make caption multiline too

---------

Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>

* correction

---------

Signed-off-by: parneet-guraya <gurayaparneet@gmail.com>
Co-authored-by: Akshay Komar <146421342+Akshaykomar890@users.noreply.github.com>
Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>
Co-authored-by: translatewiki.net <l10n-bot@translatewiki.net>
Co-authored-by: Ifeoluwa Andrew Omole <iomole3@gmail.com>
Co-authored-by: Parneet Singh <111801812+parneet-guraya@users.noreply.github.com>
Co-authored-by: Matija Nalis <mnalis-git@voyager.hr>
This commit is contained in:
Sujal 2025-02-07 06:33:38 +05:30 committed by GitHub
parent 1e77b1457a
commit 12cadd0186
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3630 additions and 3529 deletions

View file

@ -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<PagedList<Contribution>> failedAndPendingContributionList;
LiveData<PagedList<Contribution>> pendingContributionList;
LiveData<PagedList<Contribution>> 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<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> 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<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> 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<Intent> 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<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> 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:
* <p>
* Location is taken from the EXIF if the default camera application does not redact location
* tags.
* <p>
* 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<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> 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<Intent> resultLauncher, final boolean allowMultipleUploads) {
initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads);
}
/**
* Initiate gallery picker with permission
*/
public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher<Intent> 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<Intent> 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<Intent> 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<UploadableFile> imagesFiles,
FilePicker.ImageSource source, int type) {
Intent intent = handleImagesPicked(activity, imagesFiles);
activity.startActivity(intent);
}
});
}
public List<UploadableFile> 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<UploadableFile> 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<Integer, Contribution> 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<Integer, Contribution> 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<Integer, Contribution> 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();
// }
}

View file

@ -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<PagedList<Contribution>> failedAndPendingContributionList;
@JvmField
var pendingContributionList: LiveData<PagedList<Contribution>>? = null
@JvmField
var failedContributionList: LiveData<PagedList<Contribution>>? = 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<Array<String>>,
resultLauncher: ActivityResultLauncher<Intent>
) {
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<Array<String>>?,
resultLauncher: ActivityResultLauncher<Intent>
) {
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<Intent>
) {
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<Array<String>>?,
resultLauncher: ActivityResultLauncher<Intent>
) {
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<Array<String>>,
resultLauncher: ActivityResultLauncher<Intent>
) {
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<Intent>,
allowMultipleUploads: Boolean
) {
initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads)
}
/**
* Initiate gallery picker with permission
*/
fun initiateCustomGalleryPickWithPermission(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>
) {
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<Intent>,
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<Intent>
) {
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<UploadableFile>,
source: FilePicker.ImageSource, type: Int
) {
val intent = handleImagesPicked(activity, imagesFiles)
activity.startActivity(intent)
}
})
}
fun handleExternalImagesPicked(
activity: Activity,
data: Intent?
): List<UploadableFile> {
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<UploadableFile>
): 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>(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<Integer, Contribution> 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"
}
}

View file

@ -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<Integer, Contribution> 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<List<Long>> save(List<Contribution> 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<Integer> 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<Integer> states) {
return Completable
.fromAction(() -> deleteContributionsWithStatesSynchronous(states));
}
@Query("SELECT * from contribution WHERE media_filename=:fileName")
public abstract List<Contribution> 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<List<Contribution>> getContribution(List<Integer> 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<Integer, Contribution> getContributions(
List<Integer> 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<Integer, Contribution> getContributionsSortedByDateUploadStarted(
List<Integer> states);
@Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)")
public abstract Single<Integer> 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<Integer> 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<Integer> states, int newState) {
return Completable
.fromAction(() -> {
updateContributionsState(states, newState);
});
}
}

View file

@ -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<Int, Contribution>
@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<Contribution>): Single<List<Long>>
@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<Int>)
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<Int>): Completable {
return Completable
.fromAction { deleteContributionsWithStatesSynchronous(states) }
}
@Query("SELECT * from contribution WHERE media_filename=:fileName")
abstract fun getContributionWithTitle(fileName: String): List<Contribution>
@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<Int>): Single<List<Contribution>>
/**
* 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<Int>
): DataSource.Factory<Int, Contribution>
/**
* 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<Int>
): DataSource.Factory<Int, Contribution>
@Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)")
abstract fun getPendingUploads(toUpdateStates: IntArray): Single<Int>
@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<Int>, 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<Int>, newState: Int): Completable {
return Completable
.fromAction {
updateContributionsState(states, newState)
}
}
}

View file

@ -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;
}
}

View file

@ -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)
}
}

View file

@ -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<ContributionsContract.View> {
Contribution getContributionsWithTitle(String uri);
}
}

View file

@ -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<View> {
fun getContributionsWithTitle(uri: String): Contribution
}
}

View file

@ -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<String[]> nearbyLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
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<Notification> 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.
}
}

View file

@ -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<Array<String>, Map<String, Boolean>>(
ActivityResultContracts.RequestMultiplePermissions(),
object : ActivityResultCallback<Map<String, Boolean>> {
override fun onActivityResult(result: Map<String, Boolean>) {
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<View>(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<Notification> ->
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<PagedList<Contribution>> { list: PagedList<Contribution> ->
updatePendingIcon(list.size)
})
contributionController!!.failedContributions
contributionController!!.failedContributionList!!.observe(
viewLifecycleOwner,
Observer<PagedList<Contribution>> { list: PagedList<Contribution> ->
updateErrorIcon(list.size)
})
}
fun scrollToTop() {
if (contributionsListFragment != null) {
contributionsListFragment!!.scrollToTop()
}
}
private fun initNotificationViews(notificationList: List<Notification>) {
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<WorkInfo?> ->
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
}
}
}

View file

@ -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<Contribution, ContributionViewHolder> {
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<Contribution> DIFF_CALLBACK =
new DiffUtil.ItemCallback<Contribution>() {
@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);
}
}

View file

@ -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<Contribution, ContributionViewHolder>(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<Contribution> =
object : DiffUtil.ItemCallback<Contribution>() {
override fun areItemsTheSame(
oldContribution: Contribution,
newContribution: Contribution
): Boolean {
return oldContribution.pageId == newContribution.pageId
}
override fun areContentsTheSame(
oldContribution: Contribution,
newContribution: Contribution
): Boolean {
return oldContribution == newContribution
}
}
}
}

View file

@ -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<View> {
void refreshList(SwipeRefreshLayout swipeRefreshLayout);
}
}

View file

@ -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<View?> {
fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?)
}
}

View file

@ -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<Intent> galleryPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> customSelectorLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(
new RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
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();
}
}

View file

@ -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<Array<String>>
@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<Intent, ActivityResult>(
StartActivityForResult()
) { result: ActivityResult? ->
controller!!.handleActivityResultWithCallback(requireActivity()
) { callbacks: FilePicker.Callbacks? ->
controller!!.onPictureReturnedFromGallery(
result!!, requireActivity(), callbacks!!
)
}
}
private val customSelectorLauncherForResult = registerForActivityResult<Intent, ActivityResult>(
StartActivityForResult()
) { result: ActivityResult? ->
controller!!.handleActivityResultWithCallback(requireActivity()
) { callbacks: FilePicker.Callbacks? ->
controller!!.onPictureReturnedFromCustomSelector(
result!!, requireActivity(), callbacks!!
)
}
}
private val cameraPickLauncherForResult = registerForActivityResult<Intent, ActivityResult>(
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<Contribution>? ->
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<Parcelable>(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"
}
}

View file

@ -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<PagedList<Contribution>> 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<Integer, Contribution> 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<Integer, Contribution>() {
@NonNull
@Override
public DataSource<Integer, Contribution> 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;
});
}
}

View file

@ -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<PagedList<Contribution>>? = 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<Int, Contribution>
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<Int, Contribution>() {
override fun create(): DataSource<Int, Contribution> {
return contributionsRemoteDataSource
}
}
shouldSetBoundaryCallback = false
} else {
contributionBoundaryCallback.userName = userName
shouldSetBoundaryCallback = true
factory = repository.fetchContributionsWithStates(
listOf(Contribution.STATE_COMPLETED)
)
}
val livePagedListBuilder: LivePagedListBuilder<Int, Contribution> = 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
}
}
}

View file

@ -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<Contribution> 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<Integer> states) {
return contributionDao.deleteContributionsWithStates(states);
}
public Factory<Integer, Contribution> 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<Integer, Contribution> getContributionsWithStates(List<Integer> 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<Integer, Contribution> getContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return contributionDao.getContributionsSortedByDateUploadStarted(states);
}
public Single<List<Long>> saveContributions(final List<Contribution> contributions) {
final List<Contribution> 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<Integer> states, int newState) {
return contributionDao.updateContributionsWithStates(states, newState);
}
}

View file

@ -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<Int>): Completable {
return contributionDao.deleteContributionsWithStates(states)
}
fun getContributions(): DataSource.Factory<Int, Contribution> {
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<Int>): DataSource.Factory<Int, Contribution> {
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<Int>
): DataSource.Factory<Int, Contribution> {
return contributionDao.getContributionsSortedByDateUploadStarted(states)
}
fun saveContributions(contributions: List<Contribution>): Single<List<Long>> {
val contributionList: MutableList<Contribution> = 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<Int>, newState: Int): Completable {
return contributionDao.updateContributionsWithStates(states, newState)
}
}

View file

@ -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);
}

View file

@ -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?
}

View file

@ -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)));
}
}

View file

@ -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
)
})
}
}

View file

@ -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
}
}

View file

@ -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<Integer> states) {
return localDataSource.deleteContributionsWithStates(states);
}
/**
* Get contribution object with title
*
* @param fileName
* @return
*/
public Contribution getContributionWithFileName(String fileName) {
return localDataSource.getContributionWithFileName(fileName);
}
public Factory<Integer, Contribution> 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<Integer, Contribution> fetchContributionsWithStates(List<Integer> 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<Integer, Contribution> fetchContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states);
}
public Single<List<Long>> save(List<Contribution> 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<Integer> states, int newState) {
return localDataSource.updateContributionsWithStates(states, newState);
}
}

View file

@ -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<Int>): 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<Int, Contribution> {
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<Int>): DataSource.Factory<Int, Contribution> {
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<Int>
): DataSource.Factory<Int, Contribution> {
return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states)
}
fun save(contributions: List<Contribution>): Single<List<Long>> {
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<Int>, newState: Int): Completable {
return localDataSource.updateContributionsWithStates(states, newState)
}
}

View file

@ -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.
* <p>
* 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.
* <p>
* 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<Contribution> 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;
}
}

View file

@ -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)
}
}
}

View file

@ -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<CloseableReference<CloseableImage>>
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);
}
}
}

View file

@ -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<CloseableReference<CloseableImage>>?) {
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
}
}

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -43,7 +43,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() {
/**
* Callback for handling confirm button clicked
*/
interface Callback {
fun interface Callback {
fun onConfirmClicked(
contribution: Contribution?,
copyWikicode: Boolean,

View file

@ -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

View file

@ -15,8 +15,8 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje
@Inject @JvmField
var childFragmentInjector: DispatchingAndroidInjector<Fragment>? = 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")
}
}

View file

@ -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 -> {

View file

@ -426,7 +426,7 @@ object FilePicker : Constants {
fun onCanceled(source: ImageSource, type: Int)
}
interface HandleActivityResult {
fun interface HandleActivityResult {
fun onHandleActivityResult(callbacks: Callbacks)
}
}

View file

@ -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

View file

@ -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())

View file

@ -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<String> 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<String> 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<List<Place>> 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();
}
/**

View file

@ -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<Int> {
fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single<Int> {
return uploadModel.checkDuplicateImage(originalFilePath, modifiedFilePath)
}

View file

@ -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,13 +88,17 @@ 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
)
}
}
/**
* Pauses all the uploads by changing the state of contributions from STATE_QUEUED and

View file

@ -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() {

View file

@ -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
}
}
}

View file

@ -149,7 +149,7 @@ class UploadWorker(
currentNotification.build(),
)
contribution!!.transferred = transferred
contributionDao.update(contribution).blockingAwait()
contributionDao.update(contribution!!).blockingAwait()
}
open fun onChunkUploaded(

View file

@ -115,10 +115,9 @@ class ContributionViewHolderUnitTests {
@Throws(Exception::class)
fun testDisplayWikipediaButton() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method =
ContributionViewHolder::class.java.getDeclaredMethod(
val method: Method = ContributionViewHolder::class.java.getDeclaredMethod(
"displayWikipediaButton",
Boolean::class.javaObjectType,
Boolean::class.javaPrimitiveType
)
method.isAccessible = true
method.invoke(contributionViewHolder, false)

View file

@ -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

View file

@ -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)