From 771f370f9a3e34d17d29c2b0d39e89d380d4e311 Mon Sep 17 00:00:00 2001 From: Saifuddin Adenwala Date: Mon, 2 Dec 2024 13:24:26 +0530 Subject: [PATCH 1/3] Migration of locationpicker module from Java to Kotlin (#5981) * Rename .java to .kt * Migrated location picker module from Java to Kotlin --- .../LocationPicker/LocationPicker.java | 77 -- .../commons/LocationPicker/LocationPicker.kt | 72 ++ .../LocationPickerActivity.java | 681 ------------------ .../LocationPicker/LocationPickerActivity.kt | 678 +++++++++++++++++ .../LocationPickerConstants.java | 20 - .../LocationPicker/LocationPickerConstants.kt | 13 + .../LocationPickerViewModel.java | 63 -- .../LocationPicker/LocationPickerViewModel.kt | 44 ++ 8 files changed, 807 insertions(+), 841 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java create mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java create mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java create mode 100644 app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.kt diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java deleted file mode 100644 index 58801c4992..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.java +++ /dev/null @@ -1,77 +0,0 @@ -package fr.free.nrw.commons.LocationPicker; - -import android.app.Activity; -import android.content.Intent; -import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.Media; - -/** - * Helper class for starting the activity - */ -public final class LocationPicker { - - /** - * Getting camera position from the intent using constants - * - * @param data intent - * @return CameraPosition - */ - public static CameraPosition getCameraPosition(final Intent data) { - return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); - } - - public static class IntentBuilder { - - private final Intent intent; - - /** - * Creates a new builder that creates an intent to launch the place picker activity. - */ - public IntentBuilder() { - intent = new Intent(); - } - - /** - * Gets and puts location in intent - * @param position CameraPosition - * @return LocationPicker.IntentBuilder - */ - public LocationPicker.IntentBuilder defaultLocation( - final CameraPosition position) { - intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position); - return this; - } - - /** - * Gets and puts activity name in intent - * @param activity activity key - * @return LocationPicker.IntentBuilder - */ - public LocationPicker.IntentBuilder activityKey( - final String activity) { - intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity); - return this; - } - - /** - * Gets and puts media in intent - * @param media Media - * @return LocationPicker.IntentBuilder - */ - public LocationPicker.IntentBuilder media( - final Media media) { - intent.putExtra(LocationPickerConstants.MEDIA, media); - return this; - } - - /** - * Gets and sets the activity - * @param activity Activity - * @return Intent - */ - public Intent build(final Activity activity) { - intent.setClass(activity, LocationPickerActivity.class); - return intent; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.kt b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.kt new file mode 100644 index 0000000000..0bab502012 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPicker.kt @@ -0,0 +1,72 @@ +package fr.free.nrw.commons.LocationPicker + +import android.app.Activity +import android.content.Intent +import fr.free.nrw.commons.CameraPosition +import fr.free.nrw.commons.Media + + +/** + * Helper class for starting the activity + */ +object LocationPicker { + + /** + * Getting camera position from the intent using constants + * + * @param data intent + * @return CameraPosition + */ + @JvmStatic + fun getCameraPosition(data: Intent): CameraPosition? { + return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION) + } + + class IntentBuilder + /** + * Creates a new builder that creates an intent to launch the place picker activity. + */() { + + private val intent: Intent = Intent() + + /** + * Gets and puts location in intent + * @param position CameraPosition + * @return LocationPicker.IntentBuilder + */ + fun defaultLocation(position: CameraPosition): IntentBuilder { + intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position) + return this + } + + /** + * Gets and puts activity name in intent + * @param activity activity key + * @return LocationPicker.IntentBuilder + */ + fun activityKey(activity: String): IntentBuilder { + intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity) + return this + } + + /** + * Gets and puts media in intent + * @param media Media + * @return LocationPicker.IntentBuilder + */ + fun media(media: Media): IntentBuilder { + intent.putExtra(LocationPickerConstants.MEDIA, media) + return this + } + + /** + * Gets and sets the activity + * @param activity Activity + * @return Intent + */ + fun build(activity: Activity): Intent { + intent.setClass(activity, LocationPickerActivity::class.java) + return intent + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java deleted file mode 100644 index 40f360a243..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.java +++ /dev/null @@ -1,681 +0,0 @@ -package fr.free.nrw.commons.LocationPicker; - -import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; -import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM; -import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL; - -import android.Manifest.permission; -import android.annotation.SuppressLint; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.drawable.Drawable; -import android.location.LocationManager; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.MotionEvent; -import android.view.View; -import android.view.Window; -import android.view.animation.OvershootInterpolator; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.AppCompatTextView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.CommonsApplication; -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.auth.csrf.CsrfTokenClient; -import fr.free.nrw.commons.coordinates.CoordinateEditHelper; -import fr.free.nrw.commons.filepicker.Constants; -import fr.free.nrw.commons.kvstore.BasicKvStore; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.SystemThemeUtils; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.List; -import java.util.Locale; -import javax.inject.Inject; -import javax.inject.Named; -import org.osmdroid.tileprovider.tilesource.TileSourceFactory; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.util.constants.GeoConstants; -import org.osmdroid.views.CustomZoomButtonsController; -import org.osmdroid.views.overlay.Marker; -import org.osmdroid.views.overlay.Overlay; -import org.osmdroid.views.overlay.ScaleDiskOverlay; -import org.osmdroid.views.overlay.TilesOverlay; -import timber.log.Timber; - -/** - * Helps to pick location and return the result with an intent - */ -public class LocationPickerActivity extends BaseActivity implements - LocationPermissionCallback { - /** - * coordinateEditHelper: helps to edit coordinates - */ - @Inject - CoordinateEditHelper coordinateEditHelper; - /** - * media : Media object - */ - private Media media; - /** - * cameraPosition : position of picker - */ - private CameraPosition cameraPosition; - /** - * markerImage : picker image - */ - private ImageView markerImage; - /** - * mapView : OSM Map - */ - private org.osmdroid.views.MapView mapView; - /** - * tvAttribution : credit - */ - private AppCompatTextView tvAttribution; - /** - * activity : activity key - */ - private String activity; - /** - * modifyLocationButton : button for start editing location - */ - Button modifyLocationButton; - /** - * removeLocationButton : button to remove location metadata - */ - Button removeLocationButton; - /** - * showInMapButton : button for showing in map - */ - TextView showInMapButton; - /** - * placeSelectedButton : fab for selecting location - */ - FloatingActionButton placeSelectedButton; - /** - * fabCenterOnLocation: button for center on location; - */ - FloatingActionButton fabCenterOnLocation; - /** - * shadow : imageview of shadow - */ - private ImageView shadow; - /** - * largeToolbarText : textView of shadow - */ - private TextView largeToolbarText; - /** - * smallToolbarText : textView of shadow - */ - private TextView smallToolbarText; - /** - * applicationKvStore : for storing values - */ - @Inject - @Named("default_preferences") - public - JsonKvStore applicationKvStore; - BasicKvStore store; - /** - * isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly - */ - @Inject - SystemThemeUtils systemThemeUtils; - private boolean isDarkTheme; - private boolean moveToCurrentLocation; - - @Inject - LocationServiceManager locationManager; - LocationPermissionsHelper locationPermissionsHelper; - - @Inject - SessionManager sessionManager; - - /** - * Constants - */ - private static final String CAMERA_POS = "cameraPosition"; - private static final String ACTIVITY = "activity"; - - - @SuppressLint("ClickableViewAccessibility") - @Override - protected void onCreate(@Nullable final Bundle savedInstanceState) { - getWindow().requestFeature(Window.FEATURE_ACTION_BAR); - super.onCreate(savedInstanceState); - - isDarkTheme = systemThemeUtils.isDeviceInNightMode(); - moveToCurrentLocation = false; - store = new BasicKvStore(this, "LocationPermissions"); - - getWindow().requestFeature(Window.FEATURE_ACTION_BAR); - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.hide(); - } - setContentView(R.layout.activity_location_picker); - - if (savedInstanceState == null) { - cameraPosition = getIntent() - .getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION); - activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY); - media = getIntent().getParcelableExtra(LocationPickerConstants.MEDIA); - }else{ - cameraPosition = savedInstanceState.getParcelable(CAMERA_POS); - activity = savedInstanceState.getString(ACTIVITY); - media = savedInstanceState.getParcelable("sMedia"); - } - bindViews(); - addBackButtonListener(); - addPlaceSelectedButton(); - addCredits(); - getToolbarUI(); - addCenterOnGPSButton(); - - org.osmdroid.config.Configuration.getInstance().load(getApplicationContext(), - PreferenceManager.getDefaultSharedPreferences(getApplicationContext())); - - mapView.setTileSource(TileSourceFactory.WIKIMEDIA); - mapView.setTilesScaledToDpi(true); - mapView.setMultiTouchControls(true); - - org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put( - "Referer", "http://maps.wikimedia.org/" - ); - mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); - mapView.getController().setZoom(ZOOM_LEVEL); - mapView.setOnTouchListener((v, event) -> { - if (event.getAction() == MotionEvent.ACTION_MOVE) { - if (markerImage.getTranslationY() == 0) { - markerImage.animate().translationY(-75) - .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); - } - } else if (event.getAction() == MotionEvent.ACTION_UP) { - markerImage.animate().translationY(0) - .setInterpolator(new OvershootInterpolator()).setDuration(250).start(); - } - return false; - }); - - if ("UploadActivity".equals(activity)) { - placeSelectedButton.setVisibility(View.GONE); - modifyLocationButton.setVisibility(View.VISIBLE); - removeLocationButton.setVisibility(View.VISIBLE); - showInMapButton.setVisibility(View.VISIBLE); - largeToolbarText.setText(getResources().getString(R.string.image_location)); - smallToolbarText.setText(getResources(). - getString(R.string.check_whether_location_is_correct)); - fabCenterOnLocation.setVisibility(View.GONE); - markerImage.setVisibility(View.GONE); - shadow.setVisibility(View.GONE); - assert cameraPosition != null; - showSelectedLocationMarker(new GeoPoint(cameraPosition.getLatitude(), - cameraPosition.getLongitude())); - } - setupMapView(); - } - - /** - * Moves the center of the map to the specified coordinates - * - */ - private void moveMapTo(double latitude, double longitude){ - if(mapView != null && mapView.getController() != null){ - GeoPoint point = new GeoPoint(latitude, longitude); - - mapView.getController().setCenter(point); - mapView.getController().animateTo(point); - } - } - - /** - * Moves the center of the map to the specified coordinates - * @param point The GeoPoint object which contains the coordinates to move to - */ - private void moveMapTo(GeoPoint point){ - if(point != null){ - moveMapTo(point.getLatitude(), point.getLongitude()); - } - } - - /** - * For showing credits - */ - private void addCredits() { - tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); - tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); - } - - /** - * For setting up Dark Theme - */ - private void darkThemeSetup() { - if (isDarkTheme) { - shadow.setColorFilter(Color.argb(255, 255, 255, 255)); - mapView.getOverlayManager().getTilesOverlay() - .setColorFilter(TilesOverlay.INVERT_COLORS); - } - } - - /** - * Clicking back button destroy locationPickerActivity - */ - private void addBackButtonListener() { - final ImageView backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button); - backButton.setOnClickListener(v -> { - finish(); - }); - - } - - /** - * Binds mapView and location picker icon - */ - private void bindViews() { - mapView = findViewById(R.id.map_view); - markerImage = findViewById(R.id.location_picker_image_view_marker); - tvAttribution = findViewById(R.id.tv_attribution); - modifyLocationButton = findViewById(R.id.modify_location); - removeLocationButton = findViewById(R.id.remove_location); - showInMapButton = findViewById(R.id.show_in_map); - showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase( - Locale.ROOT)); - shadow = findViewById(R.id.location_picker_image_view_shadow); - } - - /** - * Gets toolbar color - */ - private void getToolbarUI() { - final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar); - largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view); - smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view); - toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor)); - } - - private void setupMapView() { - requestLocationPermissions(); - - //If location metadata is available, move map to that location. - if(activity.equals("UploadActivity") || activity.equals("MediaActivity")){ - moveMapToMediaLocation(); - } else { - //If location metadata is not available, move map to device GPS location. - moveMapToGPSLocation(); - } - - modifyLocationButton.setOnClickListener(v -> onClickModifyLocation()); - removeLocationButton.setOnClickListener(v -> onClickRemoveLocation()); - showInMapButton.setOnClickListener(v -> showInMapApp()); - darkThemeSetup(); - } - - /** - * Handles onclick event of modifyLocationButton - */ - private void onClickModifyLocation() { - placeSelectedButton.setVisibility(View.VISIBLE); - modifyLocationButton.setVisibility(View.GONE); - removeLocationButton.setVisibility(View.GONE); - showInMapButton.setVisibility(View.GONE); - markerImage.setVisibility(View.VISIBLE); - shadow.setVisibility(View.VISIBLE); - largeToolbarText.setText(getResources().getString(R.string.choose_a_location)); - smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust)); - fabCenterOnLocation.setVisibility(View.VISIBLE); - removeSelectedLocationMarker(); - moveMapToMediaLocation(); - } - - /** - * Handles onclick event of removeLocationButton - */ - private void onClickRemoveLocation() { - DialogUtil.showAlertDialog(this, - getString(R.string.remove_location_warning_title), - getString(R.string.remove_location_warning_desc), - getString(R.string.continue_message), - getString(R.string.cancel), () -> removeLocationFromImage(), null); - } - - /** - * Method to remove the location from the picture - */ - private void removeLocationFromImage() { - if (media != null) { - getCompositeDisposable().add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext() - , media, "0.0", "0.0", "0.0f") - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - Timber.d("Coordinates are removed from the image"); - })); - } - final Intent returningIntent = new Intent(); - setResult(AppCompatActivity.RESULT_OK, returningIntent); - finish(); - } - - /** - * Show the location in map app. Map will center on the location metadata, if available. - * If there is no location metadata, the map will center on the commons app map center. - */ - private void showInMapApp() { - fr.free.nrw.commons.location.LatLng position = null; - - if(activity.equals("UploadActivity") && cameraPosition != null){ - //location metadata is available - position = new fr.free.nrw.commons.location.LatLng(cameraPosition.getLatitude(), - cameraPosition.getLongitude(), 0.0f); - } else if(mapView != null){ - //location metadata is not available - position = new fr.free.nrw.commons.location.LatLng(mapView.getMapCenter().getLatitude(), - mapView.getMapCenter().getLongitude(), 0.0f); - } - - if(position != null){ - Utils.handleGeoCoordinates(this, position); - } - } - - /** - * Moves the center of the map to the media's location, if that data - * is available. - */ - private void moveMapToMediaLocation() { - if (cameraPosition != null) { - - GeoPoint point = new GeoPoint(cameraPosition.getLatitude(), - cameraPosition.getLongitude()); - - moveMapTo(point); - } - } - - /** - * Moves the center of the map to the device's GPS location, if that data is available. - */ - private void moveMapToGPSLocation(){ - if(locationManager != null){ - fr.free.nrw.commons.location.LatLng location = locationManager.getLastLocation(); - - if(location != null){ - GeoPoint point = new GeoPoint(location.getLatitude(), location.getLongitude()); - - moveMapTo(point); - } - } - } - - /** - * Select the preferable location - */ - private void addPlaceSelectedButton() { - placeSelectedButton = findViewById(R.id.location_chosen_button); - placeSelectedButton.setOnClickListener(view -> placeSelected()); - } - - /** - * Return the intent with required data - */ - void placeSelected() { - if (activity.equals("NoLocationUploadActivity")) { - applicationKvStore.putString(LAST_LOCATION, - mapView.getMapCenter().getLatitude() - + "," - + mapView.getMapCenter().getLongitude()); - applicationKvStore.putString(LAST_ZOOM, mapView.getZoomLevel() + ""); - } - - if (media == null) { - final Intent returningIntent = new Intent(); - returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, - new CameraPosition(mapView.getMapCenter().getLatitude(), - mapView.getMapCenter().getLongitude(), 14.0)); - setResult(AppCompatActivity.RESULT_OK, returningIntent); - } else { - updateCoordinates(String.valueOf(mapView.getMapCenter().getLatitude()), - String.valueOf(mapView.getMapCenter().getLongitude()), - String.valueOf(0.0f)); - } - - finish(); - } - - /** - * Fetched coordinates are replaced with existing coordinates by a POST API call. - * @param Latitude to be added - * @param Longitude to be added - * @param Accuracy to be added - */ - public void updateCoordinates(final String Latitude, final String Longitude, - final String Accuracy) { - if (media == null) { - return; - } - - try { - getCompositeDisposable().add( - coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media, - Latitude, Longitude, Accuracy) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - Timber.d("Coordinates are added."); - })); - } catch (Exception e) { - if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - this, - getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - this, logoutListener); - } - } - } - - /** - * Center the camera on the last saved location - */ - private void addCenterOnGPSButton() { - fabCenterOnLocation = findViewById(R.id.center_on_gps); - fabCenterOnLocation.setOnClickListener(view -> { - moveToCurrentLocation = true; - requestLocationPermissions(); - }); - } - - /** - * Adds selected location marker on the map - */ - private void showSelectedLocationMarker(GeoPoint point) { - Drawable icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker); - Marker marker = new Marker(mapView); - marker.setPosition(point); - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM); - marker.setIcon(icon); - marker.setInfoWindow(null); - mapView.getOverlays().add(marker); - mapView.invalidate(); - } - - /** - * Removes selected location marker from the map - */ - private void removeSelectedLocationMarker() { - List overlays = mapView.getOverlays(); - for (int i = 0; i < overlays.size(); i++) { - if (overlays.get(i) instanceof Marker) { - Marker item = (Marker) overlays.get(i); - if (cameraPosition.getLatitude() == item.getPosition().getLatitude() - && cameraPosition.getLongitude() == item.getPosition().getLongitude()) { - mapView.getOverlays().remove(i); - mapView.invalidate(); - break; - } - } - } - } - - /** - * Center the map at user's current location - */ - private void requestLocationPermissions() { - locationPermissionsHelper = new LocationPermissionsHelper( - this, locationManager, this); - locationPermissionsHelper.requestForLocationAccess(R.string.location_permission_title, - R.string.upload_map_location_access); - } - - @Override - public void onRequestPermissionsResult(final int requestCode, - @NonNull final String[] permissions, - @NonNull final int[] grantResults) { - if (requestCode == Constants.RequestCodes.LOCATION - && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - onLocationPermissionGranted(); - } else { - onLocationPermissionDenied(getString(R.string.upload_map_location_access)); - } - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - - @Override - protected void onResume() { - super.onResume(); - mapView.onResume(); - } - - @Override - protected void onPause() { - super.onPause(); - mapView.onPause(); - } - - @Override - public void onLocationPermissionDenied(String toastMessage) { - if (!ActivityCompat.shouldShowRequestPermissionRationale(this, - permission.ACCESS_FINE_LOCATION)) { - if (!locationPermissionsHelper.checkLocationPermission(this)) { - if (store.getBoolean("isPermissionDenied", false)) { - // means user has denied location permission twice or checked the "Don't show again" - locationPermissionsHelper.showAppSettingsDialog(this, - R.string.upload_map_location_access); - } else { - Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show(); - } - store.putBoolean("isPermissionDenied", true); - } - } else { - Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show(); - } - } - - @Override - public void onLocationPermissionGranted() { - if (moveToCurrentLocation || !(activity.equals("MediaActivity"))) { - if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { - locationManager.requestLocationUpdatesFromProvider( - LocationManager.NETWORK_PROVIDER); - locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); - addMarkerAtGPSLocation(); - } else { - addMarkerAtGPSLocation(); - locationPermissionsHelper.showLocationOffDialog(this, - R.string.ask_to_turn_location_on_text); - } - } - } - - /** - * Adds a marker to the map at the most recent GPS location - * (which may be the current GPS location). - */ - private void addMarkerAtGPSLocation() { - fr.free.nrw.commons.location.LatLng currLocation = locationManager.getLastLocation(); - if (currLocation != null) { - GeoPoint currLocationGeopoint = new GeoPoint(currLocation.getLatitude(), - currLocation.getLongitude()); - addLocationMarker(currLocationGeopoint); - markerImage.setTranslationY(0); - } - } - - private void addLocationMarker(GeoPoint geoPoint) { - if (moveToCurrentLocation) { - mapView.getOverlays().clear(); - } - ScaleDiskOverlay diskOverlay = - new ScaleDiskOverlay(this, - geoPoint, 2000, GeoConstants.UnitOfMeasure.foot); - Paint circlePaint = new Paint(); - circlePaint.setColor(Color.rgb(128, 128, 128)); - circlePaint.setStyle(Paint.Style.STROKE); - circlePaint.setStrokeWidth(2f); - diskOverlay.setCirclePaint2(circlePaint); - Paint diskPaint = new Paint(); - diskPaint.setColor(Color.argb(40, 128, 128, 128)); - diskPaint.setStyle(Paint.Style.FILL_AND_STROKE); - diskOverlay.setCirclePaint1(diskPaint); - diskOverlay.setDisplaySizeMin(900); - diskOverlay.setDisplaySizeMax(1700); - mapView.getOverlays().add(diskOverlay); - org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( - mapView); - startMarker.setPosition(geoPoint); - startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, - org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM); - startMarker.setIcon( - ContextCompat.getDrawable(this, R.drawable.current_location_marker)); - startMarker.setTitle("Your Location"); - startMarker.setTextLabelFontSize(24); - mapView.getOverlays().add(startMarker); - } - - /** - * Saves the state of the activity - * @param outState Bundle - */ - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - if(cameraPosition!=null){ - outState.putParcelable(CAMERA_POS, cameraPosition); - } - if(activity!=null){ - outState.putString(ACTIVITY, activity); - } - - if(media!=null){ - outState.putParcelable("sMedia", media); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt new file mode 100644 index 0000000000..6508c4f256 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt @@ -0,0 +1,678 @@ +package fr.free.nrw.commons.LocationPicker + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.Paint +import android.location.LocationManager +import android.os.Bundle +import android.preference.PreferenceManager +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.MotionEvent +import android.view.View +import android.view.Window +import android.view.animation.OvershootInterpolator +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.AppCompatTextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import fr.free.nrw.commons.CameraPosition +import fr.free.nrw.commons.CommonsApplication +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.auth.csrf.CsrfTokenClient +import fr.free.nrw.commons.coordinates.CoordinateEditHelper +import fr.free.nrw.commons.filepicker.Constants +import fr.free.nrw.commons.kvstore.BasicKvStore +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationPermissionsHelper +import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.util.constants.GeoConstants +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.ScaleDiskOverlay +import org.osmdroid.views.overlay.TilesOverlay +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named + + +/** + * Helps to pick location and return the result with an intent + */ +class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { + /** + * coordinateEditHelper: helps to edit coordinates + */ + @Inject + lateinit var coordinateEditHelper: CoordinateEditHelper + + /** + * media : Media object + */ + private var media: Media? = null + + /** + * cameraPosition : position of picker + */ + private var cameraPosition: CameraPosition? = null + + /** + * markerImage : picker image + */ + private lateinit var markerImage: ImageView + + /** + * mapView : OSM Map + */ + private var mapView: org.osmdroid.views.MapView? = null + + /** + * tvAttribution : credit + */ + private lateinit var tvAttribution: AppCompatTextView + + /** + * activity : activity key + */ + private var activity: String? = null + + /** + * modifyLocationButton : button for start editing location + */ + private lateinit var modifyLocationButton: Button + + /** + * removeLocationButton : button to remove location metadata + */ + private lateinit var removeLocationButton: Button + + /** + * showInMapButton : button for showing in map + */ + private lateinit var showInMapButton: TextView + + /** + * placeSelectedButton : fab for selecting location + */ + private lateinit var placeSelectedButton: FloatingActionButton + + /** + * fabCenterOnLocation: button for center on location; + */ + private lateinit var fabCenterOnLocation: FloatingActionButton + + /** + * shadow : imageview of shadow + */ + private lateinit var shadow: ImageView + + /** + * largeToolbarText : textView of shadow + */ + private lateinit var largeToolbarText: TextView + + /** + * smallToolbarText : textView of shadow + */ + private lateinit var smallToolbarText: TextView + + /** + * applicationKvStore : for storing values + */ + @Inject + @field: Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + private lateinit var store: BasicKvStore + + /** + * isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly + */ + private var isDarkTheme: Boolean = false + private var moveToCurrentLocation: Boolean = false + + @Inject + lateinit var locationManager: LocationServiceManager + private lateinit var locationPermissionsHelper: LocationPermissionsHelper + + @Inject + lateinit var sessionManager: SessionManager + + /** + * Constants + */ + companion object { + private const val CAMERA_POS = "cameraPosition" + private const val ACTIVITY = "activity" + } + + @SuppressLint("ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + requestWindowFeature(Window.FEATURE_ACTION_BAR) + super.onCreate(savedInstanceState) + + isDarkTheme = systemThemeUtils.isDeviceInNightMode() + moveToCurrentLocation = false + store = BasicKvStore(this, "LocationPermissions") + + requestWindowFeature(Window.FEATURE_ACTION_BAR) + supportActionBar?.hide() + setContentView(R.layout.activity_location_picker) + + if (savedInstanceState == null) { + cameraPosition = intent.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION) + activity = intent.getStringExtra(LocationPickerConstants.ACTIVITY_KEY) + media = intent.getParcelableExtra(LocationPickerConstants.MEDIA) + } else { + cameraPosition = savedInstanceState.getParcelable(CAMERA_POS) + activity = savedInstanceState.getString(ACTIVITY) + media = savedInstanceState.getParcelable("sMedia") + } + + bindViews() + addBackButtonListener() + addPlaceSelectedButton() + addCredits() + getToolbarUI() + addCenterOnGPSButton() + + org.osmdroid.config.Configuration.getInstance() + .load( + applicationContext, PreferenceManager.getDefaultSharedPreferences( + applicationContext + ) + ) + + mapView?.setTileSource(TileSourceFactory.WIKIMEDIA) + mapView?.setTilesScaledToDpi(true) + mapView?.setMultiTouchControls(true) + + org.osmdroid.config.Configuration.getInstance().additionalHttpRequestProperties["Referer"] = + "http://maps.wikimedia.org/" + mapView?.zoomController?.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + mapView?.controller?.setZoom(ZOOM_LEVEL.toDouble()) + mapView?.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_MOVE -> { + if (markerImage.translationY == 0f) { + markerImage.animate().translationY(-75f) + .setInterpolator(OvershootInterpolator()).duration = 250 + } + } + MotionEvent.ACTION_UP -> { + markerImage.animate().translationY(0f) + .setInterpolator(OvershootInterpolator()).duration = 250 + } + } + false + } + + if (activity == "UploadActivity") { + placeSelectedButton.visibility = View.GONE + modifyLocationButton.visibility = View.VISIBLE + removeLocationButton.visibility = View.VISIBLE + showInMapButton.visibility = View.VISIBLE + largeToolbarText.text = getString(R.string.image_location) + smallToolbarText.text = getString(R.string.check_whether_location_is_correct) + fabCenterOnLocation.visibility = View.GONE + markerImage.visibility = View.GONE + shadow.visibility = View.GONE + cameraPosition?.let { + showSelectedLocationMarker(GeoPoint(it.latitude, it.longitude)) + } + } + setupMapView() + } + + /** + * Moves the center of the map to the specified coordinates + */ + private fun moveMapTo(latitude: Double, longitude: Double) { + mapView?.controller?.let { + val point = GeoPoint(latitude, longitude) + it.setCenter(point) + it.animateTo(point) + } + } + + /** + * Moves the center of the map to the specified coordinates + * @param point The GeoPoint object which contains the coordinates to move to + */ + private fun moveMapTo(point: GeoPoint?) { + point?.let { + moveMapTo(it.latitude, it.longitude) + } + } + + /** + * For showing credits + */ + private fun addCredits() { + tvAttribution.text = Html.fromHtml(getString(R.string.map_attribution)) + tvAttribution.movementMethod = LinkMovementMethod.getInstance() + } + + /** + * For setting up Dark Theme + */ + private fun darkThemeSetup() { + if (isDarkTheme) { + shadow.setColorFilter(Color.argb(255, 255, 255, 255)) + mapView?.overlayManager?.tilesOverlay?.setColorFilter(TilesOverlay.INVERT_COLORS) + } + } + + /** + * Clicking back button destroy locationPickerActivity + */ + private fun addBackButtonListener() { + val backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button) + backButton.setOnClickListener { + finish() + } + } + + /** + * Binds mapView and location picker icon + */ + private fun bindViews() { + mapView = findViewById(R.id.map_view) + markerImage = findViewById(R.id.location_picker_image_view_marker) + tvAttribution = findViewById(R.id.tv_attribution) + modifyLocationButton = findViewById(R.id.modify_location) + removeLocationButton = findViewById(R.id.remove_location) + showInMapButton = findViewById(R.id.show_in_map) + showInMapButton.text = getString(R.string.show_in_map_app).uppercase(Locale.ROOT) + shadow = findViewById(R.id.location_picker_image_view_shadow) + } + + /** + * Gets toolbar color + */ + private fun getToolbarUI() { + val toolbar: ConstraintLayout = findViewById(R.id.location_picker_toolbar) + largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view) + smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view) + toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.primaryColor)) + } + + private fun setupMapView() { + requestLocationPermissions() + + //If location metadata is available, move map to that location. + if (activity == "UploadActivity" || activity == "MediaActivity") { + moveMapToMediaLocation() + } else { + //If location metadata is not available, move map to device GPS location. + moveMapToGPSLocation() + } + + modifyLocationButton.setOnClickListener { onClickModifyLocation() } + removeLocationButton.setOnClickListener { onClickRemoveLocation() } + showInMapButton.setOnClickListener { showInMapApp() } + darkThemeSetup() + } + + /** + * Handles onClick event of modifyLocationButton + */ + private fun onClickModifyLocation() { + placeSelectedButton.visibility = View.VISIBLE + modifyLocationButton.visibility = View.GONE + removeLocationButton.visibility = View.GONE + showInMapButton.visibility = View.GONE + markerImage.visibility = View.VISIBLE + shadow.visibility = View.VISIBLE + largeToolbarText.text = getString(R.string.choose_a_location) + smallToolbarText.text = getString(R.string.pan_and_zoom_to_adjust) + fabCenterOnLocation.visibility = View.VISIBLE + removeSelectedLocationMarker() + moveMapToMediaLocation() + } + + /** + * Handles onClick event of removeLocationButton + */ + private fun onClickRemoveLocation() { + DialogUtil.showAlertDialog( + this, + getString(R.string.remove_location_warning_title), + getString(R.string.remove_location_warning_desc), + getString(R.string.continue_message), + getString(R.string.cancel), + { removeLocationFromImage() }, + null + ) + } + + /** + * Removes location metadata from the image + */ + private fun removeLocationFromImage() { + media?.let { + compositeDisposable.add( + coordinateEditHelper.makeCoordinatesEdit( + applicationContext, it, "0.0", "0.0", "0.0f" + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + Timber.d("Coordinates removed from the image") + } + ) + } + setResult(RESULT_OK, Intent()) + finish() + } + + /** + * Show location in map app + */ + private fun showInMapApp() { + val position = when { + //location metadata is available + activity == "UploadActivity" && cameraPosition != null -> { + fr.free.nrw.commons.location.LatLng(cameraPosition!!.latitude, cameraPosition!!.longitude, 0.0f) + } + //location metadata is not available + mapView != null -> { + fr.free.nrw.commons.location.LatLng( + mapView?.mapCenter?.latitude!!, + mapView?.mapCenter?.longitude!!, + 0.0f + ) + } + else -> null + } + + position?.let { Utils.handleGeoCoordinates(this, it) } + } + + /** + * Moves map to media's location + */ + private fun moveMapToMediaLocation() { + cameraPosition?.let { + moveMapTo(GeoPoint(it.latitude, it.longitude)) + } + } + + /** + * Moves map to GPS location + */ + private fun moveMapToGPSLocation() { + locationManager.lastLocation?.let { + moveMapTo(GeoPoint(it.latitude, it.longitude)) + } + } + + /** + * Adds "Place Selected" button + */ + private fun addPlaceSelectedButton() { + placeSelectedButton = findViewById(R.id.location_chosen_button) + placeSelectedButton.setOnClickListener { placeSelected() } + } + + /** + * Handles "Place Selected" action + */ + private fun placeSelected() { + if (activity == "NoLocationUploadActivity") { + applicationKvStore.putString( + LAST_LOCATION, + "${mapView?.mapCenter?.latitude},${mapView?.mapCenter?.longitude}" + ) + applicationKvStore.putString(LAST_ZOOM, mapView?.zoomLevel?.toString()!!) + } + + if (media == null) { + val intent = Intent().apply { + putExtra( + LocationPickerConstants.MAP_CAMERA_POSITION, + CameraPosition(mapView?.mapCenter?.latitude!!, mapView?.mapCenter?.longitude!!, 14.0) + ) + } + setResult(RESULT_OK, intent) + } else { + updateCoordinates( + mapView?.mapCenter?.latitude.toString(), + mapView?.mapCenter?.longitude.toString(), + "0.0f" + ) + } + + finish() + } + + /** + * Updates image with new coordinates + */ + fun updateCoordinates(latitude: String, longitude: String, accuracy: String) { + media?.let { + try { + compositeDisposable.add( + coordinateEditHelper.makeCoordinatesEdit( + applicationContext, + it, + latitude, + longitude, + accuracy + ).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + Timber.d("Coordinates updated") + } + ) + } catch (e: Exception) { + if (e.localizedMessage == CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE) { + val username = sessionManager.userName + CommonsApplication.BaseLogoutListener( + this, + getString(R.string.invalid_login_message) + , username + ).let { + CommonsApplication.instance.clearApplicationData(this, it) + } + } else { } + } + } + } + + /** + * Adds a button to center the map at user's location + */ + private fun addCenterOnGPSButton() { + fabCenterOnLocation = findViewById(R.id.center_on_gps) + fabCenterOnLocation.setOnClickListener { + moveToCurrentLocation = true + requestLocationPermissions() + } + } + + /** + * Shows a selected location marker + */ + private fun showSelectedLocationMarker(point: GeoPoint) { + val icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker) + Marker(mapView).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setIcon(icon) + infoWindow = null + mapView?.overlays?.add(this) + } + mapView?.invalidate() + } + + /** + * Removes selected location marker + */ + private fun removeSelectedLocationMarker() { + val overlays = mapView?.overlays + overlays?.filterIsInstance()?.firstOrNull { + it.position.latitude == + cameraPosition?.latitude && it.position.longitude == cameraPosition?.longitude + }?.let { + overlays.remove(it) + mapView?.invalidate() + } + } + + /** + * Centers map at user's location + */ + private fun requestLocationPermissions() { + locationPermissionsHelper = LocationPermissionsHelper(this, locationManager, this) + locationPermissionsHelper.requestForLocationAccess( + R.string.location_permission_title, + R.string.upload_map_location_access + ) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == Constants.RequestCodes.LOCATION && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + onLocationPermissionGranted() + } else { + onLocationPermissionDenied(getString(R.string.upload_map_location_access)) + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onResume() { + super.onResume() + mapView?.onResume() + } + + override fun onPause() { + super.onPause() + mapView?.onPause() + } + + override fun onLocationPermissionDenied(toastMessage: String) { + val isDeniedBefore = store.getBoolean("isPermissionDenied", false) + val showRationale = ActivityCompat.shouldShowRequestPermissionRationale(this, permission.ACCESS_FINE_LOCATION) + + if (!showRationale) { + if (!locationPermissionsHelper.checkLocationPermission(this)) { + if (isDeniedBefore) { + locationPermissionsHelper.showAppSettingsDialog(this, R.string.upload_map_location_access) + } else { + Toast.makeText(this, toastMessage, Toast.LENGTH_LONG).show() + } + store.putBoolean("isPermissionDenied", true) + } + } else { + Toast.makeText(this, toastMessage, Toast.LENGTH_LONG).show() + } + } + + override fun onLocationPermissionGranted() { + if (moveToCurrentLocation || activity != "MediaActivity") { + if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn) { + locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) + locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER) + addMarkerAtGPSLocation() + } else { + addMarkerAtGPSLocation() + locationPermissionsHelper.showLocationOffDialog(this, R.string.ask_to_turn_location_on_text) + } + } + } + + /** + * Adds a marker at the user's GPS location + */ + private fun addMarkerAtGPSLocation() { + locationManager.lastLocation?.let { + addLocationMarker(GeoPoint(it.latitude, it.longitude)) + markerImage.translationY = 0f + } + } + + private fun addLocationMarker(geoPoint: GeoPoint) { + if (moveToCurrentLocation) { + mapView?.overlays?.clear() + } + + val diskOverlay = ScaleDiskOverlay( + this, + geoPoint, + 2000, + GeoConstants.UnitOfMeasure.foot + ) + + val circlePaint = Paint().apply { + color = Color.rgb(128, 128, 128) + style = Paint.Style.STROKE + strokeWidth = 2f + } + diskOverlay.setCirclePaint2(circlePaint) + + val diskPaint = Paint().apply { + color = Color.argb(40, 128, 128, 128) + style = Paint.Style.FILL_AND_STROKE + } + diskOverlay.setCirclePaint1(diskPaint) + + diskOverlay.setDisplaySizeMin(900) + diskOverlay.setDisplaySizeMax(1700) + + mapView?.overlays?.add(diskOverlay) + + val startMarker = Marker(mapView).apply { + position = geoPoint + setAnchor( + Marker.ANCHOR_CENTER, + Marker.ANCHOR_BOTTOM + ) + icon = ContextCompat.getDrawable(this@LocationPickerActivity, R.drawable.current_location_marker) + title = "Your Location" + textLabelFontSize = 24 + } + + mapView?.overlays?.add(startMarker) + } + + /** + * Saves the state of the activity + * @param outState Bundle + */ + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + cameraPosition?.let { + outState.putParcelable(CAMERA_POS, it) + } + + activity?.let { + outState.putString(ACTIVITY, it) + } + + media?.let { + outState.putParcelable("sMedia", it) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java deleted file mode 100644 index 060a15c88c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.java +++ /dev/null @@ -1,20 +0,0 @@ -package fr.free.nrw.commons.LocationPicker; - -/** - * Constants need for location picking - */ -public final class LocationPickerConstants { - - public static final String ACTIVITY_KEY - = "location.picker.activity"; - - public static final String MAP_CAMERA_POSITION - = "location.picker.cameraPosition"; - - public static final String MEDIA - = "location.picker.media"; - - - private LocationPickerConstants() { - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.kt b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.kt new file mode 100644 index 0000000000..a1c9d989a4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerConstants.kt @@ -0,0 +1,13 @@ +package fr.free.nrw.commons.LocationPicker + +/** + * Constants need for location picking + */ +object LocationPickerConstants { + + const val ACTIVITY_KEY = "location.picker.activity" + + const val MAP_CAMERA_POSITION = "location.picker.cameraPosition" + + const val MEDIA = "location.picker.media" +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java deleted file mode 100644 index 57bb238d2b..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.java +++ /dev/null @@ -1,63 +0,0 @@ -package fr.free.nrw.commons.LocationPicker; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.MutableLiveData; -import fr.free.nrw.commons.CameraPosition; -import org.jetbrains.annotations.NotNull; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; -import timber.log.Timber; - -/** - * Observes live camera position data - */ -public class LocationPickerViewModel extends AndroidViewModel implements Callback { - - /** - * Wrapping CameraPosition with MutableLiveData - */ - private final MutableLiveData result = new MutableLiveData<>(); - - /** - * Constructor for this class - * - * @param application Application - */ - public LocationPickerViewModel(@NonNull final Application application) { - super(application); - } - - /** - * Responses on camera position changing - * - * @param call Call - * @param response Response - */ - @Override - public void onResponse(final @NotNull Call call, - final Response response) { - if (response.body() == null) { - result.setValue(null); - return; - } - result.setValue(response.body()); - } - - @Override - public void onFailure(final @NotNull Call call, final @NotNull Throwable t) { - Timber.e(t); - } - - /** - * Gets live CameraPosition - * - * @return MutableLiveData - */ - public MutableLiveData getResult() { - return result; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.kt b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.kt new file mode 100644 index 0000000000..b0b2ce6de4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerViewModel.kt @@ -0,0 +1,44 @@ +package fr.free.nrw.commons.LocationPicker + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import fr.free.nrw.commons.CameraPosition +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import timber.log.Timber + +/** + * Observes live camera position data + */ +class LocationPickerViewModel( + application: Application +): AndroidViewModel(application), Callback { + + /** + * Wrapping CameraPosition with MutableLiveData + */ + val result = MutableLiveData() + + /** + * Responses on camera position changing + * + * @param call Call + * @param response Response + */ + override fun onResponse( + call: Call, + response: Response + ) { + if(response.body() == null) { + result.value = null + return + } + result.value = response.body() + } + + override fun onFailure(call: Call, t: Throwable) { + Timber.e(t) + } +} \ No newline at end of file From 8265cc6306c771ba3dd36abee37684c710700821 Mon Sep 17 00:00:00 2001 From: Saifuddin Adenwala Date: Tue, 3 Dec 2024 11:57:11 +0530 Subject: [PATCH 2/3] Migrate location and language module from Java to Kotlin (#5988) * Rename .java to .kt * Migrated location and language module from Java to Kotlin * Changed lastLocation visibility --- .../LocationPicker/LocationPickerActivity.kt | 6 +- .../language/AppLanguageLookUpTable.java | 141 --------- .../language/AppLanguageLookUpTable.kt | 135 +++++++++ .../fr/free/nrw/commons/location/LatLng.java | 198 ------------- .../fr/free/nrw/commons/location/LatLng.kt | 150 ++++++++++ .../location/LocationPermissionsHelper.java | 186 ------------ .../location/LocationPermissionsHelper.kt | 200 +++++++++++++ .../location/LocationServiceManager.java | 274 ------------------ .../location/LocationServiceManager.kt | 255 ++++++++++++++++ .../location/LocationUpdateListener.java | 7 - .../location/LocationUpdateListener.kt | 12 + .../nrw/commons/upload/LanguagesAdapter.kt | 16 +- .../kotlin/fr/free/nrw/commons/LatLngTests.kt | 2 +- .../media/MediaDetailFragmentUnitTests.kt | 8 +- .../commons/upload/LanguagesAdapterTest.kt | 10 +- 15 files changed, 773 insertions(+), 827 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java create mode 100644 app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/location/LatLng.java create mode 100644 app/src/main/java/fr/free/nrw/commons/location/LatLng.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java create mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java create mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java create mode 100644 app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt diff --git a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt index 6508c4f256..1a5ec0a348 100644 --- a/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/LocationPicker/LocationPickerActivity.kt @@ -423,7 +423,7 @@ class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { * Moves map to GPS location */ private fun moveMapToGPSLocation() { - locationManager.lastLocation?.let { + locationManager.getLastLocation()?.let { moveMapTo(GeoPoint(it.latitude, it.longitude)) } } @@ -591,7 +591,7 @@ class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { override fun onLocationPermissionGranted() { if (moveToCurrentLocation || activity != "MediaActivity") { - if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn) { + if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER) addMarkerAtGPSLocation() @@ -606,7 +606,7 @@ class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { * Adds a marker at the user's GPS location */ private fun addMarkerAtGPSLocation() { - locationManager.lastLocation?.let { + locationManager.getLastLocation()?.let { addLocationMarker(GeoPoint(it.latitude, it.longitude)) markerImage.translationY = 0f } diff --git a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java deleted file mode 100644 index a0286a7ef5..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.java +++ /dev/null @@ -1,141 +0,0 @@ -package fr.free.nrw.commons.language; - -import android.content.Context; -import android.content.res.Resources; -import android.text.TextUtils; - -import androidx.annotation.ArrayRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import fr.free.nrw.commons.R; -import java.lang.ref.SoftReference; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -/** Immutable look up table for all app supported languages. All article languages may not be - * present in this table as it is statically bundled with the app. */ -public class AppLanguageLookUpTable { - public static final String SIMPLIFIED_CHINESE_LANGUAGE_CODE = "zh-hans"; - public static final String TRADITIONAL_CHINESE_LANGUAGE_CODE = "zh-hant"; - public static final String CHINESE_CN_LANGUAGE_CODE = "zh-cn"; - public static final String CHINESE_HK_LANGUAGE_CODE = "zh-hk"; - public static final String CHINESE_MO_LANGUAGE_CODE = "zh-mo"; - public static final String CHINESE_SG_LANGUAGE_CODE = "zh-sg"; - public static final String CHINESE_TW_LANGUAGE_CODE = "zh-tw"; - public static final String CHINESE_YUE_LANGUAGE_CODE = "zh-yue"; - public static final String CHINESE_LANGUAGE_CODE = "zh"; - public static final String NORWEGIAN_LEGACY_LANGUAGE_CODE = "no"; - public static final String NORWEGIAN_BOKMAL_LANGUAGE_CODE = "nb"; - public static final String TEST_LANGUAGE_CODE = "test"; - public static final String FALLBACK_LANGUAGE_CODE = "en"; // Must exist in preference_language_keys. - - @NonNull private final Resources resources; - - // Language codes for all app supported languages in fixed order. The special code representing - // the dynamic system language is null. - @NonNull private SoftReference> codesRef = new SoftReference<>(null); - - // English names for all app supported languages in fixed order. - @NonNull private SoftReference> canonicalNamesRef = new SoftReference<>(null); - - // Native names for all app supported languages in fixed order. - @NonNull private SoftReference> localizedNamesRef = new SoftReference<>(null); - - public AppLanguageLookUpTable(@NonNull Context context) { - resources = context.getResources(); - } - - /** - * @return Nonnull immutable list. The special code representing the dynamic system language is - * null. - */ - @NonNull - public List getCodes() { - List codes = codesRef.get(); - if (codes == null) { - codes = getStringList(R.array.preference_language_keys); - codesRef = new SoftReference<>(codes); - } - return codes; - } - - @Nullable - public String getCanonicalName(@Nullable String code) { - String name = defaultIndex(getCanonicalNames(), indexOfCode(code), null); - if (TextUtils.isEmpty(name) && !TextUtils.isEmpty(code)) { - if (code.equals(Locale.CHINESE.getLanguage())) { - name = Locale.CHINESE.getDisplayName(Locale.ENGLISH); - } else if (code.equals(NORWEGIAN_LEGACY_LANGUAGE_CODE)) { - name = defaultIndex(getCanonicalNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null); - } - } - return name; - } - - @Nullable - public String getLocalizedName(@Nullable String code) { - String name = defaultIndex(getLocalizedNames(), indexOfCode(code), null); - if (TextUtils.isEmpty(name) && !TextUtils.isEmpty(code)) { - if (code.equals(Locale.CHINESE.getLanguage())) { - name = Locale.CHINESE.getDisplayName(Locale.CHINESE); - } else if (code.equals(NORWEGIAN_LEGACY_LANGUAGE_CODE)) { - name = defaultIndex(getLocalizedNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null); - } - } - return name; - } - - public List getCanonicalNames() { - List names = canonicalNamesRef.get(); - if (names == null) { - names = getStringList(R.array.preference_language_canonical_names); - canonicalNamesRef = new SoftReference<>(names); - } - return names; - } - - public List getLocalizedNames() { - List names = localizedNamesRef.get(); - if (names == null) { - names = getStringList(R.array.preference_language_local_names); - localizedNamesRef = new SoftReference<>(names); - } - return names; - } - - public boolean isSupportedCode(@Nullable String code) { - return getCodes().contains(code); - } - - private T defaultIndex(List list, int index, T defaultValue) { - return inBounds(list, index) ? list.get(index) : defaultValue; - } - - /** - * Searches #codes for the specified language code and returns the index for use in - * #canonicalNames and #localizedNames. - * - * @param code The language code to search for. The special code representing the dynamic system - * language is null. - * @return The index of the language code or -1 if the code is not supported. - */ - private int indexOfCode(@Nullable String code) { - return getCodes().indexOf(code); - } - - /** @return Nonnull immutable list. */ - @NonNull - private List getStringList(int id) { - return Arrays.asList(getStringArray(id)); - } - - private boolean inBounds(List list, int index) { - return index >= 0 && index < list.size(); - } - - public String[] getStringArray(@ArrayRes int id) { - return resources.getStringArray(id); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt new file mode 100644 index 0000000000..6809fd79cb --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/language/AppLanguageLookUpTable.kt @@ -0,0 +1,135 @@ +package fr.free.nrw.commons.language + +import android.content.Context +import android.content.res.Resources +import android.text.TextUtils + +import androidx.annotation.ArrayRes +import fr.free.nrw.commons.R +import java.lang.ref.SoftReference +import java.util.Arrays +import java.util.Locale + + +/** Immutable look up table for all app supported languages. All article languages may not be + * present in this table as it is statically bundled with the app. */ +class AppLanguageLookUpTable(context: Context) { + + companion object { + const val SIMPLIFIED_CHINESE_LANGUAGE_CODE = "zh-hans" + const val TRADITIONAL_CHINESE_LANGUAGE_CODE = "zh-hant" + const val CHINESE_CN_LANGUAGE_CODE = "zh-cn" + const val CHINESE_HK_LANGUAGE_CODE = "zh-hk" + const val CHINESE_MO_LANGUAGE_CODE = "zh-mo" + const val CHINESE_SG_LANGUAGE_CODE = "zh-sg" + const val CHINESE_TW_LANGUAGE_CODE = "zh-tw" + const val CHINESE_YUE_LANGUAGE_CODE = "zh-yue" + const val CHINESE_LANGUAGE_CODE = "zh" + const val NORWEGIAN_LEGACY_LANGUAGE_CODE = "no" + const val NORWEGIAN_BOKMAL_LANGUAGE_CODE = "nb" + const val TEST_LANGUAGE_CODE = "test" + const val FALLBACK_LANGUAGE_CODE = "en" // Must exist in preference_language_keys. + } + + private val resources: Resources = context.resources + + // Language codes for all app supported languages in fixed order. The special code representing + // the dynamic system language is null. + private var codesRef = SoftReference>(null) + + // English names for all app supported languages in fixed order. + private var canonicalNamesRef = SoftReference>(null) + + // Native names for all app supported languages in fixed order. + private var localizedNamesRef = SoftReference>(null) + + /** + * @return Nonnull immutable list. The special code representing the dynamic system language is + * null. + */ + fun getCodes(): List { + var codes = codesRef.get() + if (codes == null) { + codes = getStringList(R.array.preference_language_keys) + codesRef = SoftReference(codes) + } + return codes + } + + fun getCanonicalName(code: String?): String? { + var name = defaultIndex(getCanonicalNames(), indexOfCode(code), null) + if (name.isNullOrEmpty() && !code.isNullOrEmpty()) { + name = when (code) { + Locale.CHINESE.language -> Locale.CHINESE.getDisplayName(Locale.ENGLISH) + NORWEGIAN_LEGACY_LANGUAGE_CODE -> + defaultIndex(getCanonicalNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null) + else -> null + } + } + return name + } + + fun getLocalizedName(code: String?): String? { + var name = defaultIndex(getLocalizedNames(), indexOfCode(code), null) + if (name.isNullOrEmpty() && !code.isNullOrEmpty()) { + name = when (code) { + Locale.CHINESE.language -> Locale.CHINESE.getDisplayName(Locale.CHINESE) + NORWEGIAN_LEGACY_LANGUAGE_CODE -> + defaultIndex(getLocalizedNames(), indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE), null) + else -> null + } + } + return name + } + + fun getCanonicalNames(): List { + var names = canonicalNamesRef.get() + if (names == null) { + names = getStringList(R.array.preference_language_canonical_names) + canonicalNamesRef = SoftReference(names) + } + return names + } + + fun getLocalizedNames(): List { + var names = localizedNamesRef.get() + if (names == null) { + names = getStringList(R.array.preference_language_local_names) + localizedNamesRef = SoftReference(names) + } + return names + } + + fun isSupportedCode(code: String?): Boolean { + return getCodes().contains(code) + } + + private fun defaultIndex(list: List, index: Int, defaultValue: T?): T? { + return if (inBounds(list, index)) list[index] else defaultValue + } + + /** + * Searches #codes for the specified language code and returns the index for use in + * #canonicalNames and #localizedNames. + * + * @param code The language code to search for. The special code representing the dynamic system + * language is null. + * @return The index of the language code or -1 if the code is not supported. + */ + private fun indexOfCode(code: String?): Int { + return getCodes().indexOf(code) + } + + /** @return Nonnull immutable list. */ + private fun getStringList(id: Int): List { + return getStringArray(id).toList() + } + + private fun inBounds(list: List<*>, index: Int): Boolean { + return index in list.indices + } + + fun getStringArray(@ArrayRes id: Int): Array { + return resources.getStringArray(id) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.java b/app/src/main/java/fr/free/nrw/commons/location/LatLng.java deleted file mode 100644 index 4970fc54fc..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LatLng.java +++ /dev/null @@ -1,198 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.location.Location; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -/** - * a latitude and longitude point with accuracy information, often of a picture - */ -public class LatLng implements Parcelable { - - private final double latitude; - private final double longitude; - private final float accuracy; - - /** - * Accepts latitude and longitude. - * North and South values are cut off at 90° - * - * @param latitude the latitude - * @param longitude the longitude - * @param accuracy the accuracy - * - * Examples: - * the Statue of Liberty is located at 40.69° N, 74.04° W - * The Statue of Liberty could be constructed as LatLng(40.69, -74.04, 1.0) - * where positive signifies north, east and negative signifies south, west. - */ - public LatLng(double latitude, double longitude, float accuracy) { - if (-180.0D <= longitude && longitude < 180.0D) { - this.longitude = longitude; - } else { - this.longitude = ((longitude - 180.0D) % 360.0D + 360.0D) % 360.0D - 180.0D; - } - this.latitude = Math.max(-90.0D, Math.min(90.0D, latitude)); - this.accuracy = accuracy; - } - /** - * An alternate constructor for this class. - * @param in A parcelable which contains the latitude, longitude, and accuracy - */ - public LatLng(Parcel in) { - latitude = in.readDouble(); - longitude = in.readDouble(); - accuracy = in.readFloat(); - } - - /** - * gets the latitude and longitude of a given non-null location - * @param location the non-null location of the user - * @return LatLng the Latitude and Longitude of a given location - */ - public static LatLng from(@NonNull Location location) { - return new LatLng(location.getLatitude(), location.getLongitude(), location.getAccuracy()); - } - - /** - * creates a hash code for the longitude and longitude - */ - public int hashCode() { - byte var1 = 1; - long var2 = Double.doubleToLongBits(this.latitude); - int var3 = 31 * var1 + (int)(var2 ^ var2 >>> 32); - var2 = Double.doubleToLongBits(this.longitude); - var3 = 31 * var3 + (int)(var2 ^ var2 >>> 32); - return var3; - } - - /** - * checks for equality of two LatLng objects - * @param o the second LatLng object - */ - public boolean equals(Object o) { - if (this == o) { - return true; - } else if (!(o instanceof LatLng)) { - return false; - } else { - LatLng var2 = (LatLng)o; - return Double.doubleToLongBits(this.latitude) == Double.doubleToLongBits(var2.latitude) && Double.doubleToLongBits(this.longitude) == Double.doubleToLongBits(var2.longitude); - } - } - - /** - * returns a string representation of the latitude and longitude - */ - public String toString() { - return "lat/lng: (" + this.latitude + "," + this.longitude + ")"; - } - - /** - * Rounds the float to 4 digits and returns absolute value. - * - * @param coordinate A coordinate value as string. - * @return String of the rounded number. - */ - private String formatCoordinate(double coordinate) { - double roundedNumber = Math.round(coordinate * 10000d) / 10000d; - double absoluteNumber = Math.abs(roundedNumber); - return String.valueOf(absoluteNumber); - } - - /** - * Returns "N" or "S" depending on the latitude. - * - * @return "N" or "S". - */ - private String getNorthSouth() { - if (this.latitude < 0) { - return "S"; - } - - return "N"; - } - - /** - * Returns "E" or "W" depending on the longitude. - * - * @return "E" or "W". - */ - private String getEastWest() { - if (this.longitude >= 0 && this.longitude < 180) { - return "E"; - } - - return "W"; - } - - /** - * Returns a nicely formatted coordinate string. Used e.g. in - * the detail view. - * - * @return The formatted string. - */ - public String getPrettyCoordinateString() { - return formatCoordinate(this.latitude) + " " + this.getNorthSouth() + ", " - + formatCoordinate(this.longitude) + " " + this.getEastWest(); - } - - /** - * Return the location accuracy in meter. - * - * @return float - */ - public float getAccuracy() { - return accuracy; - } - - /** - * Return the longitude in degrees. - * - * @return double - */ - public double getLongitude() { - return longitude; - } - - /** - * Return the latitude in degrees. - * - * @return double - */ - public double getLatitude() { - return latitude; - } - - public Uri getGmmIntentUri() { - return Uri.parse("geo:" + latitude + "," + longitude + "?z=16"); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeDouble(latitude); - dest.writeDouble(longitude); - dest.writeFloat(accuracy); - } - - public static final Creator CREATOR = new Creator() { - @Override - public LatLng createFromParcel(Parcel in) { - return new LatLng(in); - } - - @Override - public LatLng[] newArray(int size) { - return new LatLng[size]; - } - }; -} - diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt new file mode 100644 index 0000000000..4e21b93c2e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt @@ -0,0 +1,150 @@ +package fr.free.nrw.commons.location + +import android.location.Location +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round + + +/** + * A latitude and longitude point with accuracy information, often of a picture. + */ +data class LatLng( + var latitude: Double, + var longitude: Double, + val accuracy: Float +) : Parcelable { + + /** + * Accepts latitude and longitude. + * North and South values are cut off at 90° + * + * Examples: + * the Statue of Liberty is located at 40.69° N, 74.04° W + * The Statue of Liberty could be constructed as LatLng(40.69, -74.04, 1.0) + * where positive signifies north, east and negative signifies south, west. + */ + init { + val adjustedLongitude = when { + longitude in -180.0..180.0 -> longitude + else -> ((longitude - 180.0) % 360.0 + 360.0) % 360.0 - 180.0 + } + latitude = max(-90.0, min(90.0, latitude)) + longitude = adjustedLongitude + } + + /** + * Accepts a non-null [Location] and converts it to a [LatLng]. + */ + companion object { + /** + * gets the latitude and longitude of a given non-null location + * @param location the non-null location of the user + * @return LatLng the Latitude and Longitude of a given location + */ + @JvmStatic + fun from(location: Location): LatLng { + return LatLng(location.latitude, location.longitude, location.accuracy) + } + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): LatLng { + return LatLng(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } + + /** + * An alternate constructor for this class. + * @param parcel A parcelable which contains the latitude, longitude, and accuracy + */ + private constructor(parcel: Parcel) : this( + latitude = parcel.readDouble(), + longitude = parcel.readDouble(), + accuracy = parcel.readFloat() + ) + + /** + * Creates a hash code for the latitude and longitude. + */ + override fun hashCode(): Int { + var result = 1 + val latitudeBits = latitude.toBits() + result = 31 * result + (latitudeBits xor (latitudeBits ushr 32)).toInt() + val longitudeBits = longitude.toBits() + result = 31 * result + (longitudeBits xor (longitudeBits ushr 32)).toInt() + return result + } + + /** + * Checks for equality of two LatLng objects. + * @param other the second LatLng object + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LatLng) return false + return latitude.toBits() == other.latitude.toBits() && + longitude.toBits() == other.longitude.toBits() + } + + /** + * Returns a string representation of the latitude and longitude. + */ + override fun toString(): String { + return "lat/lng: ($latitude,$longitude)" + } + + /** + * Returns a nicely formatted coordinate string. Used e.g. in + * the detail view. + * + * @return The formatted string. + */ + fun getPrettyCoordinateString(): String { + return "${formatCoordinate(latitude)} ${getNorthSouth()}, " + + "${formatCoordinate(longitude)} ${getEastWest()}" + } + + /** + * Gets a URI for a Google Maps intent at the location. + */ + fun getGmmIntentUri(): Uri { + return Uri.parse("geo:$latitude,$longitude?z=16") + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeDouble(latitude) + parcel.writeDouble(longitude) + parcel.writeFloat(accuracy) + } + + override fun describeContents(): Int = 0 + + private fun formatCoordinate(coordinate: Double): String { + val roundedNumber = round(coordinate * 10000) / 10000 + return abs(roundedNumber).toString() + } + + /** + * Returns "N" or "S" depending on the latitude. + * + * @return "N" or "S". + */ + private fun getNorthSouth(): String = if (latitude < 0) "S" else "N" + + /** + * Returns "E" or "W" depending on the longitude. + * + * @return "E" or "W". + */ + private fun getEastWest(): String = if (longitude in 0.0..179.999) "E" else "W" +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java deleted file mode 100644 index 77e089c9ca..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.java +++ /dev/null @@ -1,186 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.Manifest; -import android.Manifest.permission; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.provider.Settings; -import android.widget.Toast; -import androidx.core.app.ActivityCompat; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.filepicker.Constants.RequestCodes; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; - -/** - * Helper class to handle location permissions. - * - * Location flow for fragments containing a map is as follows: - * Case 1: When location permission has never been asked for or denied before - * Check if permission is already granted or not. - * If not already granted, ask for it (if it isn't denied twice before). - * If now user grants permission, go to Case 3/4, else go to Case 2. - * - * Case 2: When location permission is just asked but has been denied - * Shows a toast to tell the user why location permission is needed. - * Also shows a rationale to the user, on agreeing to which, we go back to Case 1. - * Show current location / nearby pins / nearby images according to the default location. - * - * Case 3: When location permission are already granted, but location services are off - * Asks the user to turn on the location service, using a dialog. - * If the user rejects, checks for the last known location and shows stuff using that location. - * Also displays a toast telling the user why location should be turned on. - * - * Case 4: When location permission has been granted and location services are also on - * Do whatever is required by that particular activity / fragment using current location. - * - */ -public class LocationPermissionsHelper { - - Activity activity; - LocationServiceManager locationManager; - LocationPermissionCallback callback; - - public LocationPermissionsHelper(Activity activity, LocationServiceManager locationManager, - LocationPermissionCallback callback) { - this.activity = activity; - this.locationManager = locationManager; - this.callback = callback; - } - - /** - * Ask for location permission if the user agrees on attaching location with pictures and the - * app does not have the access to location - * - * @param dialogTitleResource Resource id of the title of the dialog - * @param dialogTextResource Resource id of the text of the dialog - */ - public void requestForLocationAccess( - int dialogTitleResource, - int dialogTextResource - ) { - if (checkLocationPermission(activity)) { - callback.onLocationPermissionGranted(); - } else { - if (ActivityCompat.shouldShowRequestPermissionRationale(activity, - permission.ACCESS_FINE_LOCATION)) { - DialogUtil.showAlertDialog(activity, activity.getString(dialogTitleResource), - activity.getString(dialogTextResource), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - ActivityCompat.requestPermissions(activity, - new String[]{permission.ACCESS_FINE_LOCATION}, 1); - }, - () -> callback.onLocationPermissionDenied( - activity.getString(R.string.upload_map_location_access)), - null, - false); - } else { - ActivityCompat.requestPermissions(activity, - new String[]{permission.ACCESS_FINE_LOCATION}, - RequestCodes.LOCATION); - } - } - } - - /** - * Shows a dialog for user to open the settings page and turn on location services - * - * @param activity Activity object - * @param dialogTextResource int id of the required string resource - */ - public void showLocationOffDialog(Activity activity, int dialogTextResource) { - 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), - () -> openLocationSettings(activity), - () -> Toast.makeText(activity, activity.getString(dialogTextResource), - Toast.LENGTH_LONG).show() - ); - } - - /** - * Opens the location access page in settings, for user to turn on location services - * - * @param activity Activtiy object - */ - public void openLocationSettings(Activity activity) { - final Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); - final PackageManager packageManager = activity.getPackageManager(); - - if (intent.resolveActivity(packageManager) != null) { - activity.startActivity(intent); - } else { - Toast.makeText(activity, R.string.cannot_open_location_settings, Toast.LENGTH_LONG) - .show(); - } - } - - /** - * Shows a dialog for user to open the app's settings page and give location permission - * - * @param activity Activity object - * @param dialogTextResource int id of the required string resource - */ - public void showAppSettingsDialog(Activity activity, int dialogTextResource) { - DialogUtil - .showAlertDialog(activity, activity.getString(R.string.location_permission_title), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> openAppSettings(activity), - () -> Toast.makeText(activity, activity.getString(dialogTextResource), - Toast.LENGTH_LONG).show() - ); - } - - /** - * Opens detailed settings page of the app for the user to turn on location services - * - * @param activity Activity object - */ - public void openAppSettings(Activity activity) { - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", activity.getPackageName(), null); - intent.setData(uri); - activity.startActivity(intent); - } - - - /** - * Check if apps have access to location even after having individual access - * - * @return Returns true if location services are on and false otherwise - */ - public boolean isLocationAccessToAppsTurnedOn() { - return (locationManager.isNetworkProviderEnabled() - || locationManager.isGPSProviderEnabled()); - } - - /** - * Checks if location permission is already granted or not - * - * @param activity Activity object - * @return Returns true if location permission is granted and false otherwise - */ - public boolean checkLocationPermission(Activity activity) { - return PermissionUtils.hasPermission(activity, - new String[]{Manifest.permission.ACCESS_FINE_LOCATION}); - } - - /** - * Handle onPermissionDenied within individual classes based on the requirements - */ - public interface LocationPermissionCallback { - - void onLocationPermissionDenied(String toastMessage); - - void onLocationPermissionGranted(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt new file mode 100644 index 0000000000..771d9efdcf --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationPermissionsHelper.kt @@ -0,0 +1,200 @@ +package fr.free.nrw.commons.location + +import android.Manifest.permission +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.core.app.ActivityCompat +import fr.free.nrw.commons.R +import fr.free.nrw.commons.filepicker.Constants.RequestCodes +import fr.free.nrw.commons.utils.DialogUtil +import fr.free.nrw.commons.utils.PermissionUtils + +/** + * Helper class to handle location permissions. + * + * Location flow for fragments containing a map is as follows: + * Case 1: When location permission has never been asked for or denied before + * Check if permission is already granted or not. + * If not already granted, ask for it (if it isn't denied twice before). + * If now user grants permission, go to Case 3/4, else go to Case 2. + * + * Case 2: When location permission is just asked but has been denied + * Shows a toast to tell the user why location permission is needed. + * Also shows a rationale to the user, on agreeing to which, we go back to Case 1. + * Show current location / nearby pins / nearby images according to the default location. + * + * Case 3: When location permission are already granted, but location services are off + * Asks the user to turn on the location service, using a dialog. + * If the user rejects, checks for the last known location and shows stuff using that location. + * Also displays a toast telling the user why location should be turned on. + * + * Case 4: When location permission has been granted and location services are also on + * Do whatever is required by that particular activity / fragment using current location. + * + */ +class LocationPermissionsHelper( + private val activity: Activity, + private val locationManager: LocationServiceManager, + private val callback: LocationPermissionCallback? +) { + + /** + * Ask for location permission if the user agrees on attaching location with pictures and the + * app does not have the access to location + * + * @param dialogTitleResource Resource id of the title of the dialog + * @param dialogTextResource Resource id of the text of the dialog + */ + fun requestForLocationAccess( + dialogTitleResource: Int, + dialogTextResource: Int + ) { + if (checkLocationPermission(activity)) { + callback?.onLocationPermissionGranted() + } else { + if (ActivityCompat.shouldShowRequestPermissionRationale( + activity, + permission.ACCESS_FINE_LOCATION + ) + ) { + DialogUtil.showAlertDialog( + activity, + activity.getString(dialogTitleResource), + activity.getString(dialogTextResource), + activity.getString(android.R.string.ok), + activity.getString(android.R.string.cancel), + { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION), + 1 + ) + }, + { + callback?.onLocationPermissionDenied( + activity.getString(R.string.upload_map_location_access) + ) + }, + null, + false + ) + } else { + ActivityCompat.requestPermissions( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION), + RequestCodes.LOCATION + ) + } + } + } + + /** + * Shows a dialog for user to open the settings page and turn on location services + * + * @param activity Activity object + * @param dialogTextResource int id of the required string resource + */ + fun showLocationOffDialog(activity: Activity, dialogTextResource: Int) { + 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), + { openLocationSettings(activity) }, + { + Toast.makeText( + activity, + activity.getString(dialogTextResource), + Toast.LENGTH_LONG + ).show() + } + ) + } + + /** + * Opens the location access page in settings, for user to turn on location services + * + * @param activity Activity object + */ + fun openLocationSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + val packageManager = activity.packageManager + + if (intent.resolveActivity(packageManager) != null) { + activity.startActivity(intent) + } else { + Toast.makeText(activity, R.string.cannot_open_location_settings, Toast.LENGTH_LONG) + .show() + } + } + + /** + * Shows a dialog for user to open the app's settings page and give location permission + * + * @param activity Activity object + * @param dialogTextResource int id of the required string resource + */ + fun showAppSettingsDialog(activity: Activity, dialogTextResource: Int) { + DialogUtil.showAlertDialog( + activity, + activity.getString(R.string.location_permission_title), + activity.getString(dialogTextResource), + activity.getString(R.string.title_app_shortcut_setting), + activity.getString(R.string.cancel), + { openAppSettings(activity) }, + { + Toast.makeText( + activity, + activity.getString(dialogTextResource), + Toast.LENGTH_LONG + ).show() + } + ) + } + + /** + * Opens detailed settings page of the app for the user to turn on location services + * + * @param activity Activity object + */ + private fun openAppSettings(activity: Activity) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", activity.packageName, null) + intent.data = uri + activity.startActivity(intent) + } + + /** + * Check if apps have access to location even after having individual access + * + * @return Returns true if location services are on and false otherwise + */ + fun isLocationAccessToAppsTurnedOn(): Boolean { + return locationManager.isNetworkProviderEnabled() || locationManager.isGPSProviderEnabled() + } + + /** + * Checks if location permission is already granted or not + * + * @param activity Activity object + * @return Returns true if location permission is granted and false otherwise + */ + fun checkLocationPermission(activity: Activity): Boolean { + return PermissionUtils.hasPermission( + activity, + arrayOf(permission.ACCESS_FINE_LOCATION) + ) + } + + /** + * Handle onPermissionDenied within individual classes based on the requirements + */ + interface LocationPermissionCallback { + fun onLocationPermissionDenied(toastMessage: String) + fun onLocationPermissionGranted() + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java deleted file mode 100644 index 4c7289ea50..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.java +++ /dev/null @@ -1,274 +0,0 @@ -package fr.free.nrw.commons.location; - -import android.Manifest.permission; -import android.app.Activity; -import android.content.Context; -import android.content.pm.PackageManager; -import android.location.Location; -import android.location.LocationListener; -import android.location.LocationManager; -import android.os.Bundle; -import androidx.core.app.ActivityCompat; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArrayList; - -import timber.log.Timber; - -public class LocationServiceManager implements LocationListener { - - // Maybe these values can be improved for efficiency - private static final long MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS = 10 * 100; - private static final long MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS = 1; - - private LocationManager locationManager; - private Location lastLocation; - //private Location lastLocationDuplicate; // Will be used for nearby card view on contributions activity - private final List locationListeners = new CopyOnWriteArrayList<>(); - private boolean isLocationManagerRegistered = false; - private Set locationExplanationDisplayed = new HashSet<>(); - private Context context; - - /** - * Constructs a new instance of LocationServiceManager. - * - * @param context the context - */ - public LocationServiceManager(Context context) { - this.context = context; - this.locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); - } - - public LatLng getLastLocation() { - if (lastLocation == null) { - lastLocation = getLastKnownLocation(); - if(lastLocation != null) { - return LatLng.from(lastLocation); - } - else { - return null; - } - } - return LatLng.from(lastLocation); - } - - private Location getLastKnownLocation() { - List providers = locationManager.getProviders(true); - Location bestLocation = null; - for (String provider : providers) { - Location l=null; - if (ActivityCompat.checkSelfPermission(context, permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED - && ActivityCompat.checkSelfPermission(context, permission.ACCESS_COARSE_LOCATION) - == PackageManager.PERMISSION_GRANTED) { - l = locationManager.getLastKnownLocation(provider); - } - if (l == null) { - continue; - } - if (bestLocation == null - || l.getAccuracy() < bestLocation.getAccuracy()) { - bestLocation = l; - } - } - if (bestLocation == null) { - return null; - } - return bestLocation; - } - - /** - * Registers a LocationManager to listen for current location. - */ - public void registerLocationManager() { - if (!isLocationManagerRegistered) { - isLocationManagerRegistered = requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) - && requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); - } - } - - /** - * Requests location updates from the specified provider. - * - * @param locationProvider the location provider - * @return true if successful - */ - public boolean requestLocationUpdatesFromProvider(String locationProvider) { - try { - // If both providers are not available - if (locationManager == null || !(locationManager.getAllProviders().contains(locationProvider))) { - return false; - } - locationManager.requestLocationUpdates(locationProvider, - MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS, - MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS, - this); - return true; - } catch (IllegalArgumentException e) { - Timber.e(e, "Illegal argument exception"); - return false; - } catch (SecurityException e) { - Timber.e(e, "Security exception"); - return false; - } - } - - /** - * Returns whether a given location is better than the current best location. - * - * @param location the location to be tested - * @param currentBestLocation the current best location - * @return LOCATION_SIGNIFICANTLY_CHANGED if location changed significantly - * LOCATION_SLIGHTLY_CHANGED if location changed slightly - */ - private LocationChangeType isBetterLocation(Location location, Location currentBestLocation) { - - if (currentBestLocation == null) { - // A new location is always better than no location - return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; - } - - // Check whether the new location fix is newer or older - long timeDelta = location.getTime() - currentBestLocation.getTime(); - boolean isSignificantlyNewer = timeDelta > MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS; - boolean isNewer = timeDelta > 0; - - // Check whether the new location fix is more or less accurate - int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy()); - boolean isLessAccurate = accuracyDelta > 0; - boolean isMoreAccurate = accuracyDelta < 0; - boolean isSignificantlyLessAccurate = accuracyDelta > 200; - - // Check if the old and new location are from the same provider - boolean isFromSameProvider = isSameProvider(location.getProvider(), - currentBestLocation.getProvider()); - - float[] results = new float[5]; - Location.distanceBetween( - currentBestLocation.getLatitude(), - currentBestLocation.getLongitude(), - location.getLatitude(), - location.getLongitude(), - results); - - // If it's been more than two minutes since the current location, use the new location - // because the user has likely moved - if (isSignificantlyNewer - || isMoreAccurate - || (isNewer && !isLessAccurate) - || (isNewer && !isSignificantlyLessAccurate && isFromSameProvider)) { - if (results[0] < 1000) { // Means change is smaller than 1000 meter - return LocationChangeType.LOCATION_SLIGHTLY_CHANGED; - } else { - return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; - } - } else{ - return LocationChangeType.LOCATION_NOT_CHANGED; - } - } - - /** - * Checks whether two providers are the same - */ - private boolean isSameProvider(String provider1, String provider2) { - if (provider1 == null) { - return provider2 == null; - } - return provider1.equals(provider2); - } - - /** - * Unregisters location manager. - */ - public void unregisterLocationManager() { - isLocationManagerRegistered = false; - locationExplanationDisplayed.clear(); - try { - locationManager.removeUpdates(this); - } catch (SecurityException e) { - Timber.e(e, "Security exception"); - } - } - - /** - * Adds a new listener to the list of location listeners. - * - * @param listener the new listener - */ - public void addLocationListener(LocationUpdateListener listener) { - if (!locationListeners.contains(listener)) { - locationListeners.add(listener); - } - } - - /** - * Removes a listener from the list of location listeners. - * - * @param listener the listener to be removed - */ - public void removeLocationListener(LocationUpdateListener listener) { - locationListeners.remove(listener); - } - - @Override - public void onLocationChanged(Location location) { - Timber.d("on location changed"); - if (isBetterLocation(location, lastLocation) - .equals(LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED)) { - lastLocation = location; - //lastLocationDuplicate = location; - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedSignificantly(LatLng.from(lastLocation)); - } - } else if (location.distanceTo(lastLocation) >= 500) { - // Update nearby notification card at every 500 meters. - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedMedium(LatLng.from(lastLocation)); - } - } - - else if (isBetterLocation(location, lastLocation) - .equals(LocationChangeType.LOCATION_SLIGHTLY_CHANGED)) { - lastLocation = location; - //lastLocationDuplicate = location; - for (LocationUpdateListener listener : locationListeners) { - listener.onLocationChangedSlightly(LatLng.from(lastLocation)); - } - } - } - - @Override - public void onStatusChanged(String provider, int status, Bundle extras) { - Timber.d("%s's status changed to %d", provider, status); - } - - @Override - public void onProviderEnabled(String provider) { - Timber.d("Provider %s enabled", provider); - } - - @Override - public void onProviderDisabled(String provider) { - Timber.d("Provider %s disabled", provider); - } - - public boolean isNetworkProviderEnabled() { - return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); - } - - public boolean isGPSProviderEnabled() { - return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); - } - - public enum LocationChangeType{ - LOCATION_SIGNIFICANTLY_CHANGED, //Went out of borders of nearby markers - LOCATION_SLIGHTLY_CHANGED, //User might be walking or driving - LOCATION_MEDIUM_CHANGED, //Between slight and significant changes, will be used for nearby card view updates. - LOCATION_NOT_CHANGED, - PERMISSION_JUST_GRANTED, - MAP_UPDATED, - SEARCH_CUSTOM_AREA, - CUSTOM_QUERY - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt new file mode 100644 index 0000000000..3a4c4b72e3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationServiceManager.kt @@ -0,0 +1,255 @@ +package fr.free.nrw.commons.location + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import androidx.core.app.ActivityCompat +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArrayList + + +class LocationServiceManager(private val context: Context) : LocationListener { + + companion object { + // Maybe these values can be improved for efficiency + private const val MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS = 10 * 100L + private const val MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS = 1f + } + + private val locationManager: LocationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + private var lastLocationVar: Location? = null + private val locationListeners = CopyOnWriteArrayList() + private var isLocationManagerRegistered = false + private val locationExplanationDisplayed = mutableSetOf() + + /** + * Constructs a new instance of LocationServiceManager. + * + */ + fun getLastLocation(): LatLng? { + if (lastLocationVar == null) { + lastLocationVar = getLastKnownLocation() + return lastLocationVar?.let { LatLng.from(it) } + } + return LatLng.from(lastLocationVar!!) + } + + private fun getLastKnownLocation(): Location? { + val providers = locationManager.getProviders(true) + var bestLocation: Location? = null + for (provider in providers) { + val location: Location? = if ( + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION) + == + PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION) + == + PackageManager.PERMISSION_GRANTED + ) { + locationManager.getLastKnownLocation(provider) + } else { + null + } + + if ( + location != null + && + (bestLocation == null || location.accuracy < bestLocation.accuracy) + ) { + bestLocation = location + } + } + return bestLocation + } + + /** + * Registers a LocationManager to listen for current location. + */ + fun registerLocationManager() { + if (!isLocationManagerRegistered) { + isLocationManagerRegistered = + requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER) && + requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER) + } + } + + /** + * Requests location updates from the specified provider. + * + * @param locationProvider the location provider + * @return true if successful + */ + fun requestLocationUpdatesFromProvider(locationProvider: String): Boolean { + return try { + if (locationManager.allProviders.contains(locationProvider)) { + locationManager.requestLocationUpdates( + locationProvider, + MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS, + MIN_LOCATION_UPDATE_REQUEST_DISTANCE_IN_METERS, + this + ) + true + } else { + false + } + } catch (e: IllegalArgumentException) { + Timber.e(e, "Illegal argument exception") + false + } catch (e: SecurityException) { + Timber.e(e, "Security exception") + false + } + } + + /** + * Returns whether a given location is better than the current best location. + * + * @param location the location to be tested + * @param currentBestLocation the current best location + * @return LOCATION_SIGNIFICANTLY_CHANGED if location changed significantly + * LOCATION_SLIGHTLY_CHANGED if location changed slightly + */ + private fun isBetterLocation(location: Location, currentBestLocation: Location?): LocationChangeType { + if (currentBestLocation == null) { + return LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED + } + + val timeDelta = location.time - currentBestLocation.time + val isSignificantlyNewer = timeDelta > MIN_LOCATION_UPDATE_REQUEST_TIME_IN_MILLIS + val isNewer = timeDelta > 0 + val accuracyDelta = (location.accuracy - currentBestLocation.accuracy).toInt() + val isMoreAccurate = accuracyDelta < 0 + val isSignificantlyLessAccurate = accuracyDelta > 200 + val isFromSameProvider = isSameProvider(location.provider, currentBestLocation.provider) + + val results = FloatArray(5) + Location.distanceBetween( + currentBestLocation.latitude, currentBestLocation.longitude, + location.latitude, location.longitude, + results + ) + + return when { + isSignificantlyNewer + || + isMoreAccurate + || + (isNewer && !isSignificantlyLessAccurate && isFromSameProvider) -> { + if (results[0] < 1000) LocationChangeType.LOCATION_SLIGHTLY_CHANGED + else LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED + } + else -> LocationChangeType.LOCATION_NOT_CHANGED + } + } + + /** + * Checks whether two providers are the same + */ + private fun isSameProvider(provider1: String?, provider2: String?): Boolean { + return provider1 == provider2 + } + + /** + * Unregisters location manager. + */ + fun unregisterLocationManager() { + isLocationManagerRegistered = false + locationExplanationDisplayed.clear() + try { + locationManager.removeUpdates(this) + } catch (e: SecurityException) { + Timber.e(e, "Security exception") + } + } + + /** + * Adds a new listener to the list of location listeners. + * + * @param listener the new listener + */ + fun addLocationListener(listener: LocationUpdateListener) { + if (!locationListeners.contains(listener)) { + locationListeners.add(listener) + } + } + + /** + * Removes a listener from the list of location listeners. + * + * @param listener the listener to be removed + */ + fun removeLocationListener(listener: LocationUpdateListener) { + locationListeners.remove(listener) + } + + override fun onLocationChanged(location: Location) { + Timber.d("on location changed") + val changeType = isBetterLocation(location, lastLocationVar) + if (changeType == LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED) { + lastLocationVar = location + locationListeners.forEach { it.onLocationChangedSignificantly(LatLng.from(location)) } + } else if (lastLocationVar?.let { location.distanceTo(it) }!! >= 500) { + locationListeners.forEach { it.onLocationChangedMedium(LatLng.from(location)) } + } else if (changeType == LocationChangeType.LOCATION_SLIGHTLY_CHANGED) { + lastLocationVar = location + locationListeners.forEach { it.onLocationChangedSlightly(LatLng.from(location)) } + } + } + + @Deprecated("Deprecated in Java", ReplaceWith( + "Timber.d(\"%s's status changed to %d\", provider, status)", + "timber.log.Timber" + ) + ) + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + Timber.d("%s's status changed to %d", provider, status) + } + + + + override fun onProviderEnabled(provider: String) { + Timber.d("Provider %s enabled", provider) + } + + override fun onProviderDisabled(provider: String) { + Timber.d("Provider %s disabled", provider) + } + + fun isNetworkProviderEnabled(): Boolean { + return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } + + fun isGPSProviderEnabled(): Boolean { + return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + } + + enum class LocationChangeType { + LOCATION_SIGNIFICANTLY_CHANGED, + LOCATION_SLIGHTLY_CHANGED, + LOCATION_MEDIUM_CHANGED, + LOCATION_NOT_CHANGED, + PERMISSION_JUST_GRANTED, + MAP_UPDATED, + SEARCH_CUSTOM_AREA, + CUSTOM_QUERY + } +} + + + + + + + + + diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java deleted file mode 100644 index 61ff26b11c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.location; - -public interface LocationUpdateListener { - void onLocationChangedSignificantly(LatLng latLng); // Will be used to update all nearby markers on the map - void onLocationChangedSlightly(LatLng latLng); // Will be used to track users motion - void onLocationChangedMedium(LatLng latLng); // Will be used updating nearby card view notification -} diff --git a/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt new file mode 100644 index 0000000000..e90cc12241 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/location/LocationUpdateListener.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.location + +interface LocationUpdateListener { + // Will be used to update all nearby markers on the map + fun onLocationChangedSignificantly(latLng: LatLng) + + // Will be used to track users motion + fun onLocationChangedSlightly(latLng: LatLng) + + // Will be used updating nearby card view notification + fun onLocationChangedMedium(latLng: LatLng) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt index 2847fa0c0b..fa825d0a67 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/LanguagesAdapter.kt @@ -42,8 +42,8 @@ class LanguagesAdapter constructor( AppLanguageLookUpTable(context) init { - languageNamesList = language.localizedNames - languageCodesList = language.codes + languageNamesList = language.getLocalizedNames() + languageCodesList = language.getCodes() } private val filter = LanguageFilter() @@ -117,7 +117,7 @@ class LanguagesAdapter constructor( */ fun getIndexOfUserDefaultLocale(context: Context): Int { val userLanguageCode = context.locale?.language ?: return DEFAULT_INDEX - return language.codes.indexOf(userLanguageCode).takeIf { it >= 0 } ?: DEFAULT_INDEX + return language.getCodes().indexOf(userLanguageCode).takeIf { it >= 0 } ?: DEFAULT_INDEX } fun getIndexOfLanguageCode(languageCode: String): Int = languageCodesList.indexOf(languageCode) @@ -128,17 +128,17 @@ class LanguagesAdapter constructor( override fun performFiltering(constraint: CharSequence?): FilterResults { val filterResults = FilterResults() val temp: LinkedHashMap = LinkedHashMap() - if (constraint != null && language.localizedNames != null) { - val length: Int = language.localizedNames.size + if (constraint != null) { + val length: Int = language.getLocalizedNames().size var i = 0 while (i < length) { - val key: String = language.codes[i] - val value: String = language.localizedNames[i] + val key: String = language.getCodes()[i] + val value: String = language.getLocalizedNames()[i] val defaultlanguagecode = getIndexOfUserDefaultLocale(context) if (value.contains(constraint, true) || Locale(key) .getDisplayName( - Locale(language.codes[defaultlanguagecode]), + Locale(language.getCodes()[defaultlanguagecode]), ).contains(constraint, true) ) { temp[key] = value diff --git a/app/src/test/kotlin/fr/free/nrw/commons/LatLngTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/LatLngTests.kt index d9ef4d6e8d..3b208b5c1b 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/LatLngTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/LatLngTests.kt @@ -62,5 +62,5 @@ class LatLngTests { private fun assertPrettyCoordinateString( expected: String, place: LatLng, - ) = assertEquals(expected, place.prettyCoordinateString) + ) = assertEquals(expected, place.getPrettyCoordinateString()) } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt index ea1d3402d3..9f73d2b81d 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt @@ -248,11 +248,11 @@ class MediaDetailFragmentUnitTests { @Throws(Exception::class) fun testOnUpdateCoordinatesClickedCurrentLocationNull() { `when`(media.coordinates).thenReturn(null) - `when`(locationManager.lastLocation).thenReturn(null) + `when`(locationManager.getLastLocation()).thenReturn(null) `when`(applicationKvStore.getString(lastLocation)).thenReturn("37.773972,-122.431297") fragment.onUpdateCoordinatesClicked() Mockito.verify(media, Mockito.times(1)).coordinates - Mockito.verify(locationManager, Mockito.times(1)).lastLocation + Mockito.verify(locationManager, Mockito.times(1)).getLastLocation() val shadowActivity: ShadowActivity = shadowOf(activity) val startedIntent = shadowActivity.nextStartedActivity val shadowIntent: ShadowIntent = shadowOf(startedIntent) @@ -276,11 +276,11 @@ class MediaDetailFragmentUnitTests { @Throws(Exception::class) fun testOnUpdateCoordinatesClickedCurrentLocationNotNull() { `when`(media.coordinates).thenReturn(null) - `when`(locationManager.lastLocation).thenReturn(LatLng(-0.000001, -0.999999, 0f)) + `when`(locationManager.getLastLocation()).thenReturn(LatLng(-0.000001, -0.999999, 0f)) `when`(applicationKvStore.getString(lastLocation)).thenReturn("37.773972,-122.431297") fragment.onUpdateCoordinatesClicked() - Mockito.verify(locationManager, Mockito.times(3)).lastLocation + Mockito.verify(locationManager, Mockito.times(3)).getLastLocation() val shadowActivity: ShadowActivity = shadowOf(activity) val startedIntent = shadowActivity.nextStartedActivity val shadowIntent: ShadowIntent = shadowOf(startedIntent) diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt index 801d4e9005..f272a8288f 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/LanguagesAdapterTest.kt @@ -54,8 +54,8 @@ class LanguagesAdapterTest { .from(context) .inflate(R.layout.row_item_languages_spinner, null) as View - languageNamesList = language.localizedNames - languageCodesList = language.codes + languageNamesList = language.getLocalizedNames() + languageCodesList = language.getCodes() languagesAdapter = LanguagesAdapter(context, selectedLanguages) } @@ -124,12 +124,12 @@ class LanguagesAdapterTest { var i = 0 var s = 0 while (i < length) { - val key: String = language.codes[i] - val value: String = language.localizedNames[i] + val key: String = language.getCodes()[i] + val value: String = language.getLocalizedNames()[i] if (value.contains(constraint, true) || Locale(key) .getDisplayName( - Locale(language.codes[defaultlanguagecode!!]), + Locale(language.getCodes()[defaultlanguagecode!!]), ).contains(constraint, true) ) { s++ From 33548fa57d9d56c5208c39aeabd40b0fc2b336ad Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Tue, 3 Dec 2024 00:47:25 -0600 Subject: [PATCH 3/3] Convert profile package to kotlin (#5979) * Convert ViewModelFactory to kotlin * Convert UpdateAvatarResponse and related test to Kotlin * Convert LeaderboardResponse and related test to kotlin * Convert LeaderboardListAdapter to kotlin * Convert UserDetailAdapter to kotlin * Convert LeaderboardListViewModel to kotlin * Convert DataSourceClass to kotlin * Convert the LeaderboardFragment to kotlin * Converted AchievementsFragment to kotlin * Revert "Converted AchievementsFragment to kotlin" This reverts commit 4fcbb81e5dd95c1eab5910cab3d728959ad569f0. --------- Co-authored-by: Nicolas Raoul --- .../profile/leaderboard/DataSourceClass.java | 125 ------ .../profile/leaderboard/DataSourceClass.kt | 79 ++++ .../leaderboard/DataSourceFactory.java | 110 ------ .../profile/leaderboard/DataSourceFactory.kt | 27 ++ .../leaderboard/LeaderboardConstants.java | 45 --- .../leaderboard/LeaderboardConstants.kt | 44 +++ .../leaderboard/LeaderboardFragment.java | 363 ------------------ .../leaderboard/LeaderboardFragment.kt | 319 +++++++++++++++ .../profile/leaderboard/LeaderboardList.java | 137 ------- .../profile/leaderboard/LeaderboardList.kt | 61 +++ .../leaderboard/LeaderboardListAdapter.java | 93 ----- .../leaderboard/LeaderboardListAdapter.kt | 64 +++ .../leaderboard/LeaderboardListViewModel.java | 107 ------ .../leaderboard/LeaderboardListViewModel.kt | 54 +++ .../leaderboard/LeaderboardResponse.java | 237 ------------ .../leaderboard/LeaderboardResponse.kt | 19 + .../leaderboard/UpdateAvatarResponse.java | 77 ---- .../leaderboard/UpdateAvatarResponse.kt | 10 + .../leaderboard/UserDetailAdapter.java | 126 ------ .../profile/leaderboard/UserDetailAdapter.kt | 91 +++++ .../profile/leaderboard/ViewModelFactory.java | 41 -- .../profile/leaderboard/ViewModelFactory.kt | 26 ++ .../leaderboard/LeaderboardApiTest.java | 116 ------ .../commons/leaderboard/LeaderboardApiTest.kt | 121 ++++++ .../leaderboard/UpdateAvatarApiTest.java | 117 ------ .../leaderboard/UpdateAvatarApiTest.kt | 127 ++++++ 26 files changed, 1042 insertions(+), 1694 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java create mode 100644 app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.java create mode 100644 app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.java create mode 100644 app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.kt diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java deleted file mode 100644 index 409450d607..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.java +++ /dev/null @@ -1,125 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADED; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADING; - -import androidx.annotation.NonNull; -import androidx.lifecycle.MutableLiveData; -import androidx.paging.PageKeyedDataSource; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; -import java.util.Objects; -import timber.log.Timber; - -/** - * This class will call the leaderboard API to get new list when the pagination is performed - */ -public class DataSourceClass extends PageKeyedDataSource { - - private OkHttpJsonApiClient okHttpJsonApiClient; - private SessionManager sessionManager; - private MutableLiveData progressLiveStatus; - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private String duration; - private String category; - private int limit; - private int offset; - - /** - * Initialise the Data Source Class with API params - * @param okHttpJsonApiClient - * @param sessionManager - * @param duration - * @param category - * @param limit - * @param offset - */ - public DataSourceClass(OkHttpJsonApiClient okHttpJsonApiClient,SessionManager sessionManager, - String duration, String category, int limit, int offset) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.sessionManager = sessionManager; - this.duration = duration; - this.category = category; - this.limit = limit; - this.offset = offset; - progressLiveStatus = new MutableLiveData<>(); - } - - - /** - * @return the status of the list - */ - public MutableLiveData getProgressLiveStatus() { - return progressLiveStatus; - } - - /** - * Loads the initial set of data from API - * @param params - * @param callback - */ - @Override - public void loadInitial(@NonNull LoadInitialParams params, - @NonNull LoadInitialCallback callback) { - - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(sessionManager.getCurrentAccount()).name, - duration, category, String.valueOf(limit), String.valueOf(offset)) - .doOnSubscribe(disposable -> { - compositeDisposable.add(disposable); - progressLiveStatus.postValue(LOADING); - }).subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - progressLiveStatus.postValue(LOADED); - callback.onResult(response.getLeaderboardList(), null, response.getLimit()); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - progressLiveStatus.postValue(LOADING); - } - )); - - } - - /** - * Loads any data before the inital page is loaded - * @param params - * @param callback - */ - @Override - public void loadBefore(@NonNull LoadParams params, - @NonNull LoadCallback callback) { - - } - - /** - * Loads the next set of data on scrolling with offset as the limit of the last set of data - * @param params - * @param callback - */ - @Override - public void loadAfter(@NonNull LoadParams params, - @NonNull LoadCallback callback) { - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(sessionManager.getCurrentAccount()).name, - duration, category, String.valueOf(limit), String.valueOf(params.key)) - .doOnSubscribe(disposable -> { - compositeDisposable.add(disposable); - progressLiveStatus.postValue(LOADING); - }).subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - progressLiveStatus.postValue(LOADED); - callback.onResult(response.getLeaderboardList(), params.key + limit); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - progressLiveStatus.postValue(LOADING); - } - )); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt new file mode 100644 index 0000000000..a6fe747e52 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceClass.kt @@ -0,0 +1,79 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.accounts.Account +import androidx.lifecycle.MutableLiveData +import androidx.paging.PageKeyedDataSource +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADING +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADED +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import timber.log.Timber +import java.util.Objects + +/** + * This class will call the leaderboard API to get new list when the pagination is performed + */ +class DataSourceClass( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager, + private val duration: String?, + private val category: String?, + private val limit: Int, + private val offset: Int +) : PageKeyedDataSource() { + val progressLiveStatus: MutableLiveData = MutableLiveData() + private val compositeDisposable = CompositeDisposable() + + + override fun loadInitial( + params: LoadInitialParams, callback: LoadInitialCallback + ) { + compositeDisposable.add(okHttpJsonApiClient.getLeaderboard( + sessionManager.currentAccount?.name, + duration, + category, + limit.toString(), + offset.toString() + ).doOnSubscribe { disposable: Disposable? -> + compositeDisposable.add(disposable!!) + progressLiveStatus.postValue(LOADING) + }.subscribe({ response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + progressLiveStatus.postValue(LOADED) + callback.onResult(response.leaderboardList!!, null, response.limit) + } + }, { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + progressLiveStatus.postValue(LOADING) + })) + } + + override fun loadBefore( + params: LoadParams, callback: LoadCallback + ) = Unit + + override fun loadAfter( + params: LoadParams, callback: LoadCallback + ) { + compositeDisposable.add(okHttpJsonApiClient.getLeaderboard( + Objects.requireNonNull(sessionManager.currentAccount).name, + duration, + category, + limit.toString(), + params.key.toString() + ).doOnSubscribe { disposable: Disposable? -> + compositeDisposable.add(disposable!!) + progressLiveStatus.postValue(LOADING) + }.subscribe({ response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + progressLiveStatus.postValue(LOADED) + callback.onResult(response.leaderboardList!!, params.key + limit) + } + }, { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + progressLiveStatus.postValue(LOADING) + })) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java deleted file mode 100644 index b2965785a8..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.java +++ /dev/null @@ -1,110 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.lifecycle.MutableLiveData; -import androidx.paging.DataSource; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; - -/** - * This class will create a new instance of the data source class on pagination - */ -public class DataSourceFactory extends DataSource.Factory { - - private MutableLiveData liveData; - private OkHttpJsonApiClient okHttpJsonApiClient; - private CompositeDisposable compositeDisposable; - private SessionManager sessionManager; - private String duration; - private String category; - private int limit; - private int offset; - - /** - * Gets the current set leaderboard list duration - */ - public String getDuration() { - return duration; - } - - /** - * Sets the current set leaderboard duration with the new duration - */ - public void setDuration(final String duration) { - this.duration = duration; - } - - /** - * Gets the current set leaderboard list category - */ - public String getCategory() { - return category; - } - - /** - * Sets the current set leaderboard category with the new category - */ - public void setCategory(final String category) { - this.category = category; - } - - /** - * Gets the current set leaderboard list limit - */ - public int getLimit() { - return limit; - } - - /** - * Sets the current set leaderboard limit with the new limit - */ - public void setLimit(final int limit) { - this.limit = limit; - } - - /** - * Gets the current set leaderboard list offset - */ - public int getOffset() { - return offset; - } - - /** - * Sets the current set leaderboard offset with the new offset - */ - public void setOffset(final int offset) { - this.offset = offset; - } - - /** - * Constructor for DataSourceFactory class - * @param okHttpJsonApiClient client for OKhttp - * @param compositeDisposable composite disposable - * @param sessionManager sessionManager - */ - public DataSourceFactory(OkHttpJsonApiClient okHttpJsonApiClient, CompositeDisposable compositeDisposable, - SessionManager sessionManager) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.compositeDisposable = compositeDisposable; - this.sessionManager = sessionManager; - liveData = new MutableLiveData<>(); - } - - /** - * @return the live data - */ - public MutableLiveData getMutableLiveData() { - return liveData; - } - - /** - * Creates the new instance of data source class - * @return - */ - @Override - public DataSource create() { - DataSourceClass dataSourceClass = new DataSourceClass(okHttpJsonApiClient, sessionManager, duration, category, limit, offset); - liveData.postValue(dataSourceClass); - return dataSourceClass; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt new file mode 100644 index 0000000000..6e979d8c38 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/DataSourceFactory.kt @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.MutableLiveData +import androidx.paging.DataSource +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient + +/** + * This class will create a new instance of the data source class on pagination + */ +class DataSourceFactory( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager +) : DataSource.Factory() { + val mutableLiveData: MutableLiveData = MutableLiveData() + var duration: String? = null + var category: String? = null + var limit: Int = 0 + var offset: Int = 0 + + /** + * Creates the new instance of data source class + */ + override fun create(): DataSource = DataSourceClass( + okHttpJsonApiClient, sessionManager, duration, category, limit, offset + ).also { mutableLiveData.postValue(it) } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java deleted file mode 100644 index 800287f4fc..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.java +++ /dev/null @@ -1,45 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -/** - * This class contains the constant variables for leaderboard - */ -public class LeaderboardConstants { - - /** - * This is the size of the page i.e. number items to load in a batch when pagination is performed - */ - public static final int PAGE_SIZE = 100; - - /** - * This is the starting offset, we set it to 0 to start loading from rank 1 - */ - public static final int START_OFFSET = 0; - - /** - * This is the prefix of the user's homepage url, appending the username will give us complete url - */ - public static final String USER_LINK_PREFIX = "https://commons.wikimedia.org/wiki/User:"; - - /** - * This is the a constant string for the state loading, when the pages are getting loaded we can - * use this constant to identify if we need to show the progress bar or not - */ - public final static String LOADING = "Loading"; - - /** - * This is the a constant string for the state loaded, when the pages are loaded we can - * use this constant to identify if we need to show the progress bar or not - */ - public final static String LOADED = "Loaded"; - - /** - * This API endpoint is to update the leaderboard avatar - */ - public final static String UPDATE_AVATAR_END_POINT = "/update_avatar.py"; - - /** - * This API endpoint is to get leaderboard data - */ - public final static String LEADERBOARD_END_POINT = "/leaderboard.py"; - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt new file mode 100644 index 0000000000..bf8d45c5f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardConstants.kt @@ -0,0 +1,44 @@ +package fr.free.nrw.commons.profile.leaderboard + +/** + * This class contains the constant variables for leaderboard + */ +object LeaderboardConstants { + /** + * This is the size of the page i.e. number items to load in a batch when pagination is performed + */ + const val PAGE_SIZE: Int = 100 + + /** + * This is the starting offset, we set it to 0 to start loading from rank 1 + */ + const val START_OFFSET: Int = 0 + + /** + * This is the prefix of the user's homepage url, appending the username will give us complete url + */ + const val USER_LINK_PREFIX: String = "https://commons.wikimedia.org/wiki/User:" + + sealed class LoadingStatus { + /** + * This is the state loading, when the pages are getting loaded we can + * use this constant to identify if we need to show the progress bar or not + */ + data object LOADING: LoadingStatus() + /** + * This is the state loaded, when the pages are loaded we can + * use this constant to identify if we need to show the progress bar or not + */ + data object LOADED: LoadingStatus() + } + + /** + * This API endpoint is to update the leaderboard avatar + */ + const val UPDATE_AVATAR_END_POINT: String = "/update_avatar.py" + + /** + * This API endpoint is to get leaderboard data + */ + const val LEADERBOARD_END_POINT: String = "/leaderboard.py" +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java deleted file mode 100644 index a9cc222eaf..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.java +++ /dev/null @@ -1,363 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADED; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LOADING; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE; -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.START_OFFSET; - -import android.accounts.Account; -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemSelectedListener; -import android.widget.ArrayAdapter; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.MergeAdapter; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.databinding.FragmentLeaderboardBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.schedulers.Schedulers; -import java.util.Objects; -import javax.inject.Inject; -import timber.log.Timber; - -/** - * This class extends the CommonsDaggerSupportFragment and creates leaderboard fragment - */ -public class LeaderboardFragment extends CommonsDaggerSupportFragment { - - - @Inject - SessionManager sessionManager; - - @Inject - OkHttpJsonApiClient okHttpJsonApiClient; - - @Inject - ViewModelFactory viewModelFactory; - - /** - * View model for the paged leaderboard list - */ - private LeaderboardListViewModel viewModel; - - /** - * Composite disposable for API call - */ - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - - /** - * Duration of the leaderboard API - */ - private String duration; - - /** - * Category of the Leaderboard API - */ - private String category; - - /** - * Page size of the leaderboard API - */ - private int limit = PAGE_SIZE; - - /** - * offset for the leaderboard API - */ - private int offset = START_OFFSET; - - /** - * Set initial User Rank to 0 - */ - private int userRank; - - /** - * This variable represents if user wants to scroll to his rank or not - */ - private boolean scrollToRank; - - private String userName; - - private FragmentLeaderboardBinding binding; - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (getArguments() != null) { - userName = getArguments().getString(ProfileActivity.KEY_USERNAME); - } - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentLeaderboardBinding.inflate(inflater, container, false); - - hideLayouts(); - - // Leaderboard currently unimplemented in Beta flavor. Skip all API calls and disable menu - if(ConfigUtils.isBetaFlavour()) { - binding.progressBar.setVisibility(View.GONE); - binding.scroll.setVisibility(View.GONE); - return binding.getRoot(); - } - - binding.progressBar.setVisibility(View.VISIBLE); - setSpinners(); - - /** - * This array is for the duration filter, we have three filters weekly, yearly and all-time - * each filter have a key and value pair, the value represents the param of the API - */ - String[] durationValues = getContext().getResources().getStringArray(R.array.leaderboard_duration_values); - - /** - * This array is for the category filter, we have three filters upload, used and nearby - * each filter have a key and value pair, the value represents the param of the API - */ - String[] categoryValues = getContext().getResources().getStringArray(R.array.leaderboard_category_values); - - duration = durationValues[0]; - category = categoryValues[0]; - - setLeaderboard(duration, category, limit, offset); - - binding.durationSpinner.setOnItemSelectedListener(new OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - - duration = durationValues[binding.durationSpinner.getSelectedItemPosition()]; - refreshLeaderboard(); - } - - @Override - public void onNothingSelected(AdapterView adapterView) { - } - }); - - binding.categorySpinner.setOnItemSelectedListener(new OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - category = categoryValues[binding.categorySpinner.getSelectedItemPosition()]; - refreshLeaderboard(); - } - - @Override - public void onNothingSelected(AdapterView adapterView) { - } - }); - - - binding.scroll.setOnClickListener(view -> scrollToUserRank()); - - - return binding.getRoot(); - } - - @Override - public void setMenuVisibility(boolean visible) { - super.setMenuVisibility(visible); - - // Whenever this fragment is revealed in a menu, - // notify Beta users the page data is unavailable - if(ConfigUtils.isBetaFlavour() && visible) { - Context ctx = null; - if(getContext() != null) { - ctx = getContext(); - } else if(getView() != null && getView().getContext() != null) { - ctx = getView().getContext(); - } - if(ctx != null) { - Toast.makeText(ctx, - R.string.leaderboard_unavailable_beta, - Toast.LENGTH_LONG).show(); - } - } - } - - /** - * Refreshes the leaderboard list - */ - private void refreshLeaderboard() { - scrollToRank = false; - if (viewModel != null) { - viewModel.refresh(duration, category, limit, offset); - setLeaderboard(duration, category, limit, offset); - } - } - - /** - * Performs Auto Scroll to the User's Rank - * We use userRank+1 to load one extra user and prevent overlapping of my rank button - * If you are viewing the leaderboard below userRank, it scrolls to the user rank at the top - */ - private void scrollToUserRank() { - - if(userRank==0){ - Toast.makeText(getContext(),R.string.no_achievements_yet,Toast.LENGTH_SHORT).show(); - }else { - if (binding == null) { - return; - } - if (Objects.requireNonNull(binding.leaderboardList.getAdapter()).getItemCount() - > userRank + 1) { - binding.leaderboardList.smoothScrollToPosition(userRank + 1); - } else { - if (viewModel != null) { - viewModel.refresh(duration, category, userRank + 1, 0); - setLeaderboard(duration, category, userRank + 1, 0); - scrollToRank = true; - } - } - } - - } - - /** - * Set the spinners for the leaderboard filters - */ - private void setSpinners() { - ArrayAdapter categoryAdapter = ArrayAdapter.createFromResource(getContext(), - R.array.leaderboard_categories, android.R.layout.simple_spinner_item); - categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - binding.categorySpinner.setAdapter(categoryAdapter); - - ArrayAdapter durationAdapter = ArrayAdapter.createFromResource(getContext(), - R.array.leaderboard_durations, android.R.layout.simple_spinner_item); - durationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - binding.durationSpinner.setAdapter(durationAdapter); - } - - /** - * To call the API to get results - * which then sets the views using setLeaderboardUser method - */ - private void setLeaderboard(String duration, String category, int limit, int offset) { - if (checkAccount()) { - try { - compositeDisposable.add(okHttpJsonApiClient - .getLeaderboard(Objects.requireNonNull(userName), - duration, category, null, null) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - if (response != null && response.getStatus() == 200) { - userRank = response.getRank(); - setViews(response, duration, category, limit, offset); - } - }, - t -> { - Timber.e(t, "Fetching leaderboard statistics failed"); - onError(); - } - )); - } - catch (Exception e){ - Timber.d(e+"success"); - } - } - } - - /** - * Set the views - * @param response Leaderboard Response Object - */ - private void setViews(LeaderboardResponse response, String duration, String category, int limit, int offset) { - viewModel = new ViewModelProvider(this, viewModelFactory).get(LeaderboardListViewModel.class); - viewModel.setParams(duration, category, limit, offset); - LeaderboardListAdapter leaderboardListAdapter = new LeaderboardListAdapter(); - UserDetailAdapter userDetailAdapter= new UserDetailAdapter(response); - MergeAdapter mergeAdapter = new MergeAdapter(userDetailAdapter, leaderboardListAdapter); - LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext()); - binding.leaderboardList.setLayoutManager(linearLayoutManager); - binding.leaderboardList.setAdapter(mergeAdapter); - viewModel.getListLiveData().observe(getViewLifecycleOwner(), leaderboardListAdapter::submitList); - viewModel.getProgressLoadStatus().observe(getViewLifecycleOwner(), status -> { - if (Objects.requireNonNull(status).equalsIgnoreCase(LOADING)) { - showProgressBar(); - } else if (status.equalsIgnoreCase(LOADED)) { - hideProgressBar(); - if (scrollToRank) { - binding.leaderboardList.smoothScrollToPosition(userRank + 1); - } - } - }); - } - - /** - * to hide progressbar - */ - private void hideProgressBar() { - if (binding != null) { - binding.progressBar.setVisibility(View.GONE); - binding.categorySpinner.setVisibility(View.VISIBLE); - binding.durationSpinner.setVisibility(View.VISIBLE); - binding.scroll.setVisibility(View.VISIBLE); - binding.leaderboardList.setVisibility(View.VISIBLE); - } - } - - /** - * to show progressbar - */ - private void showProgressBar() { - if (binding != null) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.scroll.setVisibility(View.INVISIBLE); - } - } - - /** - * used to hide the layouts while fetching results from api - */ - private void hideLayouts(){ - binding.categorySpinner.setVisibility(View.INVISIBLE); - binding.durationSpinner.setVisibility(View.INVISIBLE); - binding.leaderboardList.setVisibility(View.INVISIBLE); - } - - /** - * check to ensure that user is logged in - * @return - */ - private boolean checkAccount(){ - Account currentAccount = sessionManager.getCurrentAccount(); - if (currentAccount == null) { - Timber.d("Current account is null"); - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.user_not_logged_in)); - sessionManager.forceLogin(getActivity()); - return false; - } - return true; - } - - /** - * Shows a generic error toast when error occurs while loading leaderboard - */ - private void onError() { - ViewUtil.showLongToast(getActivity(), getResources().getString(R.string.error_occurred)); - if (binding!=null) { - binding.progressBar.setVisibility(View.GONE); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - compositeDisposable.clear(); - binding = null; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt new file mode 100644 index 0000000000..e77c24c8d9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardFragment.kt @@ -0,0 +1,319 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.MergeAdapter +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.databinding.FragmentLeaderboardBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADED +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus.LOADING +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.START_OFFSET +import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour +import fr.free.nrw.commons.utils.ViewUtil.showLongToast +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Objects +import javax.inject.Inject + +/** + * This class extends the CommonsDaggerSupportFragment and creates leaderboard fragment + */ +class LeaderboardFragment : CommonsDaggerSupportFragment() { + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var okHttpJsonApiClient: OkHttpJsonApiClient + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private var viewModel: LeaderboardListViewModel? = null + private var duration: String? = null + private var category: String? = null + private val limit: Int = PAGE_SIZE + private val offset: Int = START_OFFSET + private var userRank = 0 + private var scrollToRank = false + private var userName: String? = null + private var binding: FragmentLeaderboardBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { userName = it.getString(ProfileActivity.KEY_USERNAME) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentLeaderboardBinding.inflate(inflater, container, false) + + hideLayouts() + + // Leaderboard currently unimplemented in Beta flavor. Skip all API calls and disable menu + if (isBetaFlavour) { + binding!!.progressBar.visibility = View.GONE + binding!!.scroll.visibility = View.GONE + return binding!!.root + } + + binding!!.progressBar.visibility = View.VISIBLE + setSpinners() + + /* + * This array is for the duration filter, we have three filters weekly, yearly and all-time + * each filter have a key and value pair, the value represents the param of the API + */ + val durationValues = requireContext().resources + .getStringArray(R.array.leaderboard_duration_values) + duration = durationValues[0] + + /* + * This array is for the category filter, we have three filters upload, used and nearby + * each filter have a key and value pair, the value represents the param of the API + */ + val categoryValues = requireContext().resources + .getStringArray(R.array.leaderboard_category_values) + category = categoryValues[0] + + setLeaderboard(duration, category, limit, offset) + + with(binding!!) { + durationSpinner.onItemSelectedListener = SelectionListener { + duration = durationValues[durationSpinner.selectedItemPosition] + refreshLeaderboard() + } + + categorySpinner.onItemSelectedListener = SelectionListener { + category = categoryValues[categorySpinner.selectedItemPosition] + refreshLeaderboard() + } + + scroll.setOnClickListener { scrollToUserRank() } + + return root + } + } + + override fun setMenuVisibility(visible: Boolean) { + super.setMenuVisibility(visible) + + // Whenever this fragment is revealed in a menu, + // notify Beta users the page data is unavailable + if (isBetaFlavour && visible) { + val ctx: Context? = if (context != null) { + context + } else if (view != null && requireView().context != null) { + requireView().context + } else { + null + } + + ctx?.let { + Toast.makeText(it, R.string.leaderboard_unavailable_beta, Toast.LENGTH_LONG).show() + } + } + } + + /** + * Refreshes the leaderboard list + */ + private fun refreshLeaderboard() { + scrollToRank = false + viewModel?.let { + it.refresh(duration, category, limit, offset) + setLeaderboard(duration, category, limit, offset) + } + } + + /** + * Performs Auto Scroll to the User's Rank + * We use userRank+1 to load one extra user and prevent overlapping of my rank button + * If you are viewing the leaderboard below userRank, it scrolls to the user rank at the top + */ + private fun scrollToUserRank() { + if (userRank == 0) { + Toast.makeText(context, R.string.no_achievements_yet, Toast.LENGTH_SHORT).show() + } else { + if (binding == null) { + return + } + val itemCount = binding?.leaderboardList?.adapter?.itemCount ?: 0 + if (itemCount > userRank + 1) { + binding!!.leaderboardList.smoothScrollToPosition(userRank + 1) + } else { + viewModel?.let { + it.refresh(duration, category, userRank + 1, 0) + setLeaderboard(duration, category, userRank + 1, 0) + scrollToRank = true + } + } + } + } + + /** + * Set the spinners for the leaderboard filters + */ + private fun setSpinners() { + val categoryAdapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.leaderboard_categories, android.R.layout.simple_spinner_item + ) + categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding!!.categorySpinner.adapter = categoryAdapter + + val durationAdapter = ArrayAdapter.createFromResource( + requireContext(), + R.array.leaderboard_durations, android.R.layout.simple_spinner_item + ) + durationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding!!.durationSpinner.adapter = durationAdapter + } + + /** + * To call the API to get results + * which then sets the views using setLeaderboardUser method + */ + private fun setLeaderboard(duration: String?, category: String?, limit: Int, offset: Int) { + if (checkAccount()) { + try { + compositeDisposable.add( + okHttpJsonApiClient.getLeaderboard( + Objects.requireNonNull(userName), + duration, category, null, null + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response: LeaderboardResponse? -> + if (response != null && response.status == 200) { + userRank = response.rank!! + setViews(response, duration, category, limit, offset) + } + }, + { t: Throwable? -> + Timber.e(t, "Fetching leaderboard statistics failed") + onError() + } + )) + } catch (e: Exception) { + Timber.d(e, "success") + } + } + } + + /** + * Set the views + * @param response Leaderboard Response Object + */ + private fun setViews( + response: LeaderboardResponse, + duration: String?, + category: String?, + limit: Int, + offset: Int + ) { + viewModel = ViewModelProvider(this, viewModelFactory).get( + LeaderboardListViewModel::class.java + ) + viewModel!!.setParams(duration, category, limit, offset) + val leaderboardListAdapter = LeaderboardListAdapter() + val userDetailAdapter = UserDetailAdapter(response) + val mergeAdapter = MergeAdapter(userDetailAdapter, leaderboardListAdapter) + val linearLayoutManager = LinearLayoutManager(context) + binding!!.leaderboardList.layoutManager = linearLayoutManager + binding!!.leaderboardList.adapter = mergeAdapter + viewModel!!.listLiveData.observe(viewLifecycleOwner, leaderboardListAdapter::submitList) + + viewModel!!.progressLoadStatus.observe(viewLifecycleOwner) { status -> + when (status) { + LOADING -> { + showProgressBar() + } + LOADED -> { + hideProgressBar() + if (scrollToRank) { + binding!!.leaderboardList.smoothScrollToPosition(userRank + 1) + } + } + } + } + } + + /** + * to hide progressbar + */ + private fun hideProgressBar() = binding?.let { + it.progressBar.visibility = View.GONE + it.categorySpinner.visibility = View.VISIBLE + it.durationSpinner.visibility = View.VISIBLE + it.scroll.visibility = View.VISIBLE + it.leaderboardList.visibility = View.VISIBLE + } + + /** + * to show progressbar + */ + private fun showProgressBar() = binding?.let { + it.progressBar.visibility = View.VISIBLE + it.scroll.visibility = View.INVISIBLE + } + + /** + * used to hide the layouts while fetching results from api + */ + private fun hideLayouts() = binding?.let { + it.categorySpinner.visibility = View.INVISIBLE + it.durationSpinner.visibility = View.INVISIBLE + it.leaderboardList.visibility = View.INVISIBLE + } + + /** + * check to ensure that user is logged in + */ + private fun checkAccount() = if (sessionManager.currentAccount == null) { + Timber.d("Current account is null") + showLongToast(requireActivity(), resources.getString(R.string.user_not_logged_in)) + sessionManager.forceLogin(requireActivity()) + false + } else { + true + } + + /** + * Shows a generic error toast when error occurs while loading leaderboard + */ + private fun onError() { + showLongToast(requireActivity(), resources.getString(R.string.error_occurred)) + binding?.let { it.progressBar.visibility = View.GONE } + } + + override fun onDestroy() { + super.onDestroy() + compositeDisposable.clear() + binding = null + } + + private class SelectionListener(private val handler: () -> Unit): AdapterView.OnItemSelectedListener { + override fun onItemSelected(adapterView: AdapterView<*>?, view: View, i: Int, l: Long) = + handler() + + override fun onNothingSelected(p0: AdapterView<*>?) = Unit + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java deleted file mode 100644 index 5558f3d9eb..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.java +++ /dev/null @@ -1,137 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DiffUtil.ItemCallback; -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * This class represents the leaderboard API response sub part of i.e. leaderboard list - * The leaderboard list will contain the ranking of the users from 1 to n, - * avatars, username and count in the selected category. - */ -public class LeaderboardList { - - /** - * Username of the user - * Example value - Syced - */ - @SerializedName("username") - @Expose - private String username; - - /** - * Count in the category - * Example value - 10 - */ - @SerializedName("category_count") - @Expose - private Integer categoryCount; - - /** - * URL of the avatar of user - * Example value = https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png - */ - @SerializedName("avatar") - @Expose - private String avatar; - - /** - * Rank of the user - * Example value - 1 - */ - @SerializedName("rank") - @Expose - private Integer rank; - - /** - * @return the username of the user in the leaderboard list - */ - public String getUsername() { - return username; - } - - /** - * Sets the username of the user in the leaderboard list - */ - public void setUsername(String username) { - this.username = username; - } - - /** - * @return the category count of the user in the leaderboard list - */ - public Integer getCategoryCount() { - return categoryCount; - } - - /** - * Sets the category count of the user in the leaderboard list - */ - public void setCategoryCount(Integer categoryCount) { - this.categoryCount = categoryCount; - } - - /** - * @return the avatar of the user in the leaderboard list - */ - public String getAvatar() { - return avatar; - } - - /** - * Sets the avatar of the user in the leaderboard list - */ - public void setAvatar(String avatar) { - this.avatar = avatar; - } - - /** - * @return the rank of the user in the leaderboard list - */ - public Integer getRank() { - return rank; - } - - /** - * Sets the rank of the user in the leaderboard list - */ - public void setRank(Integer rank) { - this.rank = rank; - } - - - /** - * This method checks for the diff in the callbacks for paged lists - */ - public static DiffUtil.ItemCallback DIFF_CALLBACK = - new ItemCallback() { - @Override - public boolean areItemsTheSame(@NonNull LeaderboardList oldItem, - @NonNull LeaderboardList newItem) { - return newItem == oldItem; - } - - @Override - public boolean areContentsTheSame(@NonNull LeaderboardList oldItem, - @NonNull LeaderboardList newItem) { - return newItem.getRank().equals(oldItem.getRank()); - } - }; - - /** - * Returns true if two objects are equal, false otherwise - * @param obj - * @return - */ - @Override - public boolean equals(Object obj) { - if (obj == this) { - return true; - } - - LeaderboardList leaderboardList = (LeaderboardList) obj; - return leaderboardList.getRank().equals(this.getRank()); - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt new file mode 100644 index 0000000000..dc6d93e15a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardList.kt @@ -0,0 +1,61 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.recyclerview.widget.DiffUtil +import com.google.gson.annotations.SerializedName + +/** + * This class represents the leaderboard API response sub part of i.e. leaderboard list + * The leaderboard list will contain the ranking of the users from 1 to n, + * avatars, username and count in the selected category. + */ +data class LeaderboardList ( + @SerializedName("username") + var username: String? = null, + @SerializedName("category_count") + var categoryCount: Int? = null, + @SerializedName("avatar") + var avatar: String? = null, + @SerializedName("rank") + var rank: Int? = null +) { + + /** + * Returns true if two objects are equal, false otherwise + * @param other + * @return + */ + override fun equals(other: Any?): Boolean { + if (other === this) { + return true + } + + val leaderboardList = other as LeaderboardList + return leaderboardList.rank == rank + } + + override fun hashCode(): Int { + var result = username?.hashCode() ?: 0 + result = 31 * result + (categoryCount ?: 0) + result = 31 * result + (avatar?.hashCode() ?: 0) + result = 31 * result + (rank ?: 0) + return result + } + + companion object { + /** + * This method checks for the diff in the callbacks for paged lists + */ + var DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: LeaderboardList, + newItem: LeaderboardList + ): Boolean = newItem === oldItem + + override fun areContentsTheSame( + oldItem: LeaderboardList, + newItem: LeaderboardList + ): Boolean = newItem.rank == oldItem.rank + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java deleted file mode 100644 index 9af24159af..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.java +++ /dev/null @@ -1,93 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - - -import android.app.Activity; -import android.content.Context; -import android.net.Uri; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.paging.PagedListAdapter; -import androidx.recyclerview.widget.RecyclerView; -import com.facebook.drawee.view.SimpleDraweeView; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.profile.ProfileActivity; - -/** - * This class extends RecyclerView.Adapter and creates the List section of the leaderboard - */ -public class LeaderboardListAdapter extends PagedListAdapter { - - public LeaderboardListAdapter() { - super(LeaderboardList.DIFF_CALLBACK); - } - - public class ListViewHolder extends RecyclerView.ViewHolder { - TextView rank; - SimpleDraweeView avatar; - TextView username; - TextView count; - - public ListViewHolder(View itemView) { - super(itemView); - this.rank = itemView.findViewById(R.id.user_rank); - this.avatar = itemView.findViewById(R.id.user_avatar); - this.username = itemView.findViewById(R.id.user_name); - this.count = itemView.findViewById(R.id.user_count); - } - - /** - * This method will return the Context - * @return Context - */ - public Context getContext() { - return itemView.getContext(); - } - } - - /** - * Overrides the onCreateViewHolder and inflates the recyclerview list item layout - * @param parent - * @param viewType - * @return - */ - @NonNull - @Override - public LeaderboardListAdapter.ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.leaderboard_list_element, parent, false); - - return new ListViewHolder(view); - } - - /** - * Overrides the onBindViewHolder Set the view at the specific position with the specific value - * @param holder - * @param position - */ - @Override - public void onBindViewHolder(@NonNull LeaderboardListAdapter.ListViewHolder holder, int position) { - TextView rank = holder.rank; - SimpleDraweeView avatar = holder.avatar; - TextView username = holder.username; - TextView count = holder.count; - - rank.setText(getItem(position).getRank().toString()); - - avatar.setImageURI(Uri.parse(getItem(position).getAvatar())); - username.setText(getItem(position).getUsername()); - count.setText(getItem(position).getCategoryCount().toString()); - - /* - Now that we have our in app profile-section, lets take the user there - */ - holder.itemView.setOnClickListener(view -> { - if (view.getContext() instanceof ProfileActivity) { - ((Activity) (view.getContext())).finish(); - } - ProfileActivity.startYourself(view.getContext(), getItem(position).getUsername(), true); - }); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt new file mode 100644 index 0000000000..c7bccf950c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListAdapter.kt @@ -0,0 +1,64 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.app.Activity +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.paging.PagedListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.leaderboard.LeaderboardList.Companion.DIFF_CALLBACK +import fr.free.nrw.commons.profile.leaderboard.LeaderboardListAdapter.ListViewHolder + + +/** + * This class extends RecyclerView.Adapter and creates the List section of the leaderboard + */ +class LeaderboardListAdapter : PagedListAdapter(DIFF_CALLBACK) { + inner class ListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var rank: TextView? = itemView.findViewById(R.id.user_rank) + var avatar: SimpleDraweeView? = itemView.findViewById(R.id.user_avatar) + var username: TextView? = itemView.findViewById(R.id.user_name) + var count: TextView? = itemView.findViewById(R.id.user_count) + } + + /** + * Overrides the onCreateViewHolder and inflates the recyclerview list item layout + * @param parent + * @param viewType + * @return + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder = + ListViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.leaderboard_list_element, parent, false) + ) + + /** + * Overrides the onBindViewHolder Set the view at the specific position with the specific value + * @param holder + * @param position + */ + override fun onBindViewHolder(holder: ListViewHolder, position: Int) = with (holder) { + val item = getItem(position)!! + + rank?.text = item.rank.toString() + avatar?.setImageURI(Uri.parse(item.avatar)) + username?.text = item.username + count?.text = item.categoryCount.toString() + + /* + Now that we have our in app profile-section, lets take the user there + */ + itemView.setOnClickListener { view: View -> + if (view.context is ProfileActivity) { + ((view.context) as Activity).finish() + } + ProfileActivity.startYourself(view.context, item.username, true) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java deleted file mode 100644 index 909b4f6465..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.java +++ /dev/null @@ -1,107 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import static fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE; - -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import io.reactivex.disposables.CompositeDisposable; - -/** - * Extends the ViewModel class and creates the LeaderboardList View Model - */ -public class LeaderboardListViewModel extends ViewModel { - - private DataSourceFactory dataSourceFactory; - private LiveData> listLiveData; - private CompositeDisposable compositeDisposable = new CompositeDisposable(); - private LiveData progressLoadStatus = new MutableLiveData<>(); - - /** - * Constructor for a new LeaderboardListViewModel - * @param okHttpJsonApiClient - * @param sessionManager - */ - public LeaderboardListViewModel(OkHttpJsonApiClient okHttpJsonApiClient, SessionManager - sessionManager) { - - dataSourceFactory = new DataSourceFactory(okHttpJsonApiClient, - compositeDisposable, sessionManager); - initializePaging(); - } - - - /** - * Initialises the paging - */ - private void initializePaging() { - - PagedList.Config pagedListConfig = - new PagedList.Config.Builder() - .setEnablePlaceholders(false) - .setInitialLoadSizeHint(PAGE_SIZE) - .setPageSize(PAGE_SIZE).build(); - - listLiveData = new LivePagedListBuilder<>(dataSourceFactory, pagedListConfig) - .build(); - - progressLoadStatus = Transformations - .switchMap(dataSourceFactory.getMutableLiveData(), DataSourceClass::getProgressLiveStatus); - - } - - /** - * Refreshes the paged list with the new params and starts the loading of new data - * @param duration - * @param category - * @param limit - * @param offset - */ - public void refresh(String duration, String category, int limit, int offset) { - dataSourceFactory.setDuration(duration); - dataSourceFactory.setCategory(category); - dataSourceFactory.setLimit(limit); - dataSourceFactory.setOffset(offset); - dataSourceFactory.getMutableLiveData().getValue().invalidate(); - } - - /** - * Sets the new params for the paged list API calls - * @param duration - * @param category - * @param limit - * @param offset - */ - public void setParams(String duration, String category, int limit, int offset) { - dataSourceFactory.setDuration(duration); - dataSourceFactory.setCategory(category); - dataSourceFactory.setLimit(limit); - dataSourceFactory.setOffset(offset); - } - - /** - * @return the loading status of paged list - */ - public LiveData getProgressLoadStatus() { - return progressLoadStatus; - } - - /** - * @return the paged list with live data - */ - public LiveData> getListLiveData() { - return listLiveData; - } - - @Override - protected void onCleared() { - super.onCleared(); - compositeDisposable.clear(); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt new file mode 100644 index 0000000000..7d649b67b0 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardListViewModel.kt @@ -0,0 +1,54 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.LoadingStatus +import fr.free.nrw.commons.profile.leaderboard.LeaderboardConstants.PAGE_SIZE + +/** + * Extends the ViewModel class and creates the LeaderboardList View Model + */ +class LeaderboardListViewModel( + okHttpJsonApiClient: OkHttpJsonApiClient, + sessionManager: SessionManager +) : ViewModel() { + private val dataSourceFactory = DataSourceFactory(okHttpJsonApiClient, sessionManager) + + val listLiveData: LiveData> = LivePagedListBuilder( + dataSourceFactory, + PagedList.Config.Builder() + .setEnablePlaceholders(false) + .setInitialLoadSizeHint(PAGE_SIZE) + .setPageSize(PAGE_SIZE).build() + ).build() + + val progressLoadStatus: LiveData = + dataSourceFactory.mutableLiveData.switchMap { it.progressLiveStatus } + + /** + * Refreshes the paged list with the new params and starts the loading of new data + */ + fun refresh(duration: String?, category: String?, limit: Int, offset: Int) { + dataSourceFactory.duration = duration + dataSourceFactory.category = category + dataSourceFactory.limit = limit + dataSourceFactory.offset = offset + dataSourceFactory.mutableLiveData.value!!.invalidate() + } + + /** + * Sets the new params for the paged list API calls + */ + fun setParams(duration: String?, category: String?, limit: Int, offset: Int) { + dataSourceFactory.duration = duration + dataSourceFactory.category = category + dataSourceFactory.limit = limit + dataSourceFactory.offset = offset + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java deleted file mode 100644 index 34294fca9c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.java +++ /dev/null @@ -1,237 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import java.util.List; -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * GSON Response Class for Leaderboard API response - */ -public class LeaderboardResponse { - - /** - * Status Code returned from the API - * Example value - 200 - */ - @SerializedName("status") - @Expose - private Integer status; - - /** - * Username returned from the API - * Example value - Syced - */ - @SerializedName("username") - @Expose - private String username; - - /** - * Category count returned from the API - * Example value - 10 - */ - @SerializedName("category_count") - @Expose - private Integer categoryCount; - - /** - * Limit returned from the API - * Example value - 10 - */ - @SerializedName("limit") - @Expose - private int limit; - - /** - * Avatar returned from the API - * Example value - https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png - */ - @SerializedName("avatar") - @Expose - private String avatar; - - /** - * Offset returned from the API - * Example value - 0 - */ - @SerializedName("offset") - @Expose - private int offset; - - /** - * Duration returned from the API - * Example value - yearly - */ - @SerializedName("duration") - @Expose - private String duration; - - /** - * Leaderboard list returned from the API - * Example value - [{ - * "username": "Fæ", - * "category_count": 107147, - * "avatar": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/Gnome-stock_person.svg/200px-Gnome-stock_person.svg.png", - * "rank": 1 - * }] - */ - @SerializedName("leaderboard_list") - @Expose - private List leaderboardList = null; - - /** - * Category returned from the API - * Example value - upload - */ - @SerializedName("category") - @Expose - private String category; - - /** - * Rank returned from the API - * Example value - 1 - */ - @SerializedName("rank") - @Expose - private Integer rank; - - /** - * @return the status code - */ - public Integer getStatus() { - return status; - } - - /** - * Sets the status code - */ - public void setStatus(Integer status) { - this.status = status; - } - - /** - * @return the username - */ - public String getUsername() { - return username; - } - - /** - * Sets the username - */ - public void setUsername(String username) { - this.username = username; - } - - /** - * @return the category count - */ - public Integer getCategoryCount() { - return categoryCount; - } - - /** - * Sets the category count - */ - public void setCategoryCount(Integer categoryCount) { - this.categoryCount = categoryCount; - } - - /** - * @return the limit - */ - public int getLimit() { - return limit; - } - - /** - * Sets the limit - */ - public void setLimit(int limit) { - this.limit = limit; - } - - /** - * @return the avatar - */ - public String getAvatar() { - return avatar; - } - - /** - * Sets the avatar - */ - public void setAvatar(String avatar) { - this.avatar = avatar; - } - - /** - * @return the offset - */ - public int getOffset() { - return offset; - } - - /** - * Sets the offset - */ - public void setOffset(int offset) { - this.offset = offset; - } - - /** - * @return the duration - */ - public String getDuration() { - return duration; - } - - /** - * Sets the duration - */ - public void setDuration(String duration) { - this.duration = duration; - } - - /** - * @return the leaderboard list - */ - public List getLeaderboardList() { - return leaderboardList; - } - - /** - * Sets the leaderboard list - */ - public void setLeaderboardList(List leaderboardList) { - this.leaderboardList = leaderboardList; - } - - /** - * @return the category - */ - public String getCategory() { - return category; - } - - /** - * Sets the category - */ - public void setCategory(String category) { - this.category = category; - } - - /** - * @return the rank - */ - public Integer getRank() { - return rank; - } - - /** - * Sets the rank - */ - public void setRank(Integer rank) { - this.rank = rank; - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt new file mode 100644 index 0000000000..8be3426509 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/LeaderboardResponse.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.profile.leaderboard + +import com.google.gson.annotations.SerializedName + +/** + * GSON Response Class for Leaderboard API response + */ +data class LeaderboardResponse( + @SerializedName("status") var status: Int? = null, + @SerializedName("username") var username: String? = null, + @SerializedName("category_count") var categoryCount: Int? = null, + @SerializedName("limit") var limit: Int = 0, + @SerializedName("avatar") var avatar: String? = null, + @SerializedName("offset") var offset: Int = 0, + @SerializedName("duration") var duration: String? = null, + @SerializedName("leaderboard_list") var leaderboardList: List? = null, + @SerializedName("category") var category: String? = null, + @SerializedName("rank") var rank: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java deleted file mode 100644 index 15449a4885..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.java +++ /dev/null @@ -1,77 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -/** - * GSON Response Class for Update Avatar API response - */ -public class UpdateAvatarResponse { - - /** - * Status Code returned from the API - * Example value - 200 - */ - @SerializedName("status") - @Expose - private String status; - - /** - * Message returned from the API - * Example value - Avatar Updated - */ - @SerializedName("message") - @Expose - private String message; - - /** - * Username returned from the API - * Example value - Syced - */ - @SerializedName("user") - @Expose - private String user; - - /** - * @return the status code - */ - public String getStatus() { - return status; - } - - /** - * Sets the status code - */ - public void setStatus(String status) { - this.status = status; - } - - /** - * @return the message - */ - public String getMessage() { - return message; - } - - /** - * Sets the message - */ - public void setMessage(String message) { - this.message = message; - } - - /** - * @return the username - */ - public String getUser() { - return user; - } - - /** - * Sets the username - */ - public void setUser(String user) { - this.user = user; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt new file mode 100644 index 0000000000..75fb8f2681 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UpdateAvatarResponse.kt @@ -0,0 +1,10 @@ +package fr.free.nrw.commons.profile.leaderboard + +/** + * GSON Response Class for Update Avatar API response + */ +data class UpdateAvatarResponse( + var status: String? = null, + var message: String? = null, + var user: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java deleted file mode 100644 index 75b9de938c..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.java +++ /dev/null @@ -1,126 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import android.accounts.Account; -import android.accounts.AccountManager; -import android.content.Context; -import android.net.Uri; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import com.facebook.drawee.view.SimpleDraweeView; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.R; - - -/** - * This class extends RecyclerView.Adapter and creates the UserDetail section of the leaderboard - */ -public class UserDetailAdapter extends RecyclerView.Adapter { - - private LeaderboardResponse leaderboardResponse; - - /** - * Stores the username of currently logged in user. - */ - private String currentlyLoggedInUserName = null; - - public UserDetailAdapter(LeaderboardResponse leaderboardResponse) { - this.leaderboardResponse = leaderboardResponse; - } - - public class DataViewHolder extends RecyclerView.ViewHolder { - - private TextView rank; - private SimpleDraweeView avatar; - private TextView username; - private TextView count; - - public DataViewHolder(@NonNull View itemView) { - super(itemView); - this.rank = itemView.findViewById(R.id.rank); - this.avatar = itemView.findViewById(R.id.avatar); - this.username = itemView.findViewById(R.id.username); - this.count = itemView.findViewById(R.id.count); - } - - /** - * This method will return the Context - * @return Context - */ - public Context getContext() { - return itemView.getContext(); - } - } - - /** - * Overrides the onCreateViewHolder and sets the view with leaderboard user element layout - * @param parent - * @param viewType - * @return - */ - @NonNull - @Override - public UserDetailAdapter.DataViewHolder onCreateViewHolder(@NonNull ViewGroup parent, - int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.leaderboard_user_element, parent, false); - return new DataViewHolder(view); - } - - /** - * Overrides the onBindViewHolder Set the view at the specific position with the specific value - * @param holder - * @param position - */ - @Override - public void onBindViewHolder(@NonNull UserDetailAdapter.DataViewHolder holder, int position) { - TextView rank = holder.rank; - SimpleDraweeView avatar = holder.avatar; - TextView username = holder.username; - TextView count = holder.count; - - rank.setText(String.format("%s %d", - holder.getContext().getResources().getString(R.string.rank_prefix), - leaderboardResponse.getRank())); - - avatar.setImageURI( - Uri.parse(leaderboardResponse.getAvatar())); - username.setText(leaderboardResponse.getUsername()); - count.setText(String.format("%s %d", - holder.getContext().getResources().getString(R.string.count_prefix), - leaderboardResponse.getCategoryCount())); - - // When user tap on avatar shows the toast on how to change avatar - // fixing: https://github.com/commons-app/apps-android-commons/issues/47747 - if (currentlyLoggedInUserName == null) { - // If the current login username has not been fetched yet, then fetch it. - final AccountManager accountManager = AccountManager.get(username.getContext()); - final Account[] allAccounts = accountManager.getAccountsByType( - BuildConfig.ACCOUNT_TYPE); - if (allAccounts.length != 0) { - currentlyLoggedInUserName = allAccounts[0].name; - } - } - if (currentlyLoggedInUserName != null && currentlyLoggedInUserName.equals( - leaderboardResponse.getUsername())) { - - avatar.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Toast.makeText(v.getContext(), - R.string.set_up_avatar_toast_string, - Toast.LENGTH_LONG).show(); - } - }); - } - } - - @Override - public int getItemCount() { - return 1; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt new file mode 100644 index 0000000000..34fd5ab581 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/UserDetailAdapter.kt @@ -0,0 +1,91 @@ +package fr.free.nrw.commons.profile.leaderboard + +import android.accounts.AccountManager +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.facebook.drawee.view.SimpleDraweeView +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.R +import fr.free.nrw.commons.profile.leaderboard.UserDetailAdapter.DataViewHolder +import java.util.Locale + +/** + * This class extends RecyclerView.Adapter and creates the UserDetail section of the leaderboard + */ +class UserDetailAdapter(private val leaderboardResponse: LeaderboardResponse) : + RecyclerView.Adapter() { + /** + * Stores the username of currently logged in user. + */ + private var currentlyLoggedInUserName: String? = null + + class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val rank: TextView = itemView.findViewById(R.id.rank) + val avatar: SimpleDraweeView = itemView.findViewById(R.id.avatar) + val username: TextView = itemView.findViewById(R.id.username) + val count: TextView = itemView.findViewById(R.id.count) + } + + /** + * Overrides the onCreateViewHolder and sets the view with leaderboard user element layout + * @param parent + * @param viewType + * @return + */ + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DataViewHolder = DataViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.leaderboard_user_element, parent, false) + ) + + /** + * Overrides the onBindViewHolder Set the view at the specific position with the specific value + * @param holder + * @param position + */ + override fun onBindViewHolder(holder: DataViewHolder, position: Int) = with(holder) { + val resources = itemView.context.resources + + avatar.setImageURI(Uri.parse(leaderboardResponse.avatar)) + username.text = leaderboardResponse.username + rank.text = String.format( + Locale.getDefault(), + "%s %d", + resources.getString(R.string.rank_prefix), + leaderboardResponse.rank + ) + count.text = String.format( + Locale.getDefault(), + "%s %d", + resources.getString(R.string.count_prefix), + leaderboardResponse.categoryCount + ) + + // When user tap on avatar shows the toast on how to change avatar + // fixing: https://github.com/commons-app/apps-android-commons/issues/47747 + if (currentlyLoggedInUserName == null) { + // If the current login username has not been fetched yet, then fetch it. + val accountManager = AccountManager.get(itemView.context) + val allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE) + if (allAccounts.isNotEmpty()) { + currentlyLoggedInUserName = allAccounts[0].name + } + } + if (currentlyLoggedInUserName != null && currentlyLoggedInUserName == leaderboardResponse.username) { + avatar.setOnClickListener { v: View -> + Toast.makeText( + v.context, R.string.set_up_avatar_toast_string, Toast.LENGTH_LONG + ).show() + } + } + } + + override fun getItemCount(): Int = 1 +} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java deleted file mode 100644 index fece771100..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.java +++ /dev/null @@ -1,41 +0,0 @@ -package fr.free.nrw.commons.profile.leaderboard; - -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import javax.inject.Inject; - -/** - * This class extends the ViewModelProvider.Factory and creates a ViewModelFactory class - * for leaderboardListViewModel - */ -public class ViewModelFactory implements ViewModelProvider.Factory { - - private OkHttpJsonApiClient okHttpJsonApiClient; - private SessionManager sessionManager; - - - @Inject - public ViewModelFactory(OkHttpJsonApiClient okHttpJsonApiClient, SessionManager sessionManager) { - this.okHttpJsonApiClient = okHttpJsonApiClient; - this.sessionManager = sessionManager; - } - - - /** - * Creats a new LeaderboardListViewModel - * @param modelClass - * @param - * @return - */ - @NonNull - @Override - public T create(@NonNull Class modelClass) { - if (modelClass.isAssignableFrom(LeaderboardListViewModel.class)) { - return (T) new LeaderboardListViewModel(okHttpJsonApiClient, sessionManager); - } - throw new IllegalArgumentException("Unknown class name"); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt new file mode 100644 index 0000000000..f325355e0f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/profile/leaderboard/ViewModelFactory.kt @@ -0,0 +1,26 @@ +package fr.free.nrw.commons.profile.leaderboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import javax.inject.Inject + + +/** + * This class extends the ViewModelProvider.Factory and creates a ViewModelFactory class + * for leaderboardListViewModel + */ +class ViewModelFactory @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val sessionManager: SessionManager +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + if (modelClass.isAssignableFrom(LeaderboardListViewModel::class.java)) { + LeaderboardListViewModel(okHttpJsonApiClient, sessionManager) as T + } else { + throw IllegalArgumentException("Unknown class name") + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.java b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.java deleted file mode 100644 index 51d806a88d..0000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package fr.free.nrw.commons.leaderboard; - -import com.google.gson.Gson; -import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Request.Builder; -import okhttp3.Response; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -/** - * This class tests the Leaderboard API calls - */ -public class LeaderboardApiTest { - - MockWebServer server; - private static final String TEST_USERNAME = "user"; - private static final String TEST_AVATAR = "avatar"; - private static final int TEST_USER_RANK = 1; - private static final int TEST_USER_COUNT = 0; - - private static final String FILE_NAME = "leaderboard_sample_response.json"; - private static final String ENDPOINT = "/leaderboard.py"; - - /** - * This method initialises a Mock Server - */ - @Before - public void initTest() { - server = new MockWebServer(); - } - - /** - * This method will setup a Mock Server and load Test JSON Response File - * @throws Exception - */ - @Before - public void setUp() throws Exception { - - String testResponseBody = convertStreamToString(getClass().getClassLoader().getResourceAsStream(FILE_NAME)); - - server.enqueue(new MockResponse().setBody(testResponseBody)); - server.start(); - } - - /** - * This method converts a Input Stream to String - * @param is takes Input Stream of JSON File as Parameter - * @return a String with JSON data - * @throws Exception - */ - private static String convertStreamToString(InputStream is) throws Exception { - BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - /** - * This method will call the Mock Server and Test it with sample values. - * It will test the Leaderboard API call functionality and check if the object is - * being created with the correct values - * @throws IOException - */ - @Test - public void apiTest() throws IOException { - HttpUrl httpUrl = server.url(ENDPOINT); - LeaderboardResponse response = sendRequest(new OkHttpClient(), httpUrl); - - Assert.assertEquals(TEST_AVATAR, response.getAvatar()); - Assert.assertEquals(TEST_USERNAME, response.getUsername()); - Assert.assertEquals(Integer.valueOf(TEST_USER_RANK), response.getRank()); - Assert.assertEquals(Integer.valueOf(TEST_USER_COUNT), response.getCategoryCount()); - } - - /** - * This method will call the Mock API and returns the Leaderboard Response Object - * @param okHttpClient - * @param httpUrl - * @return Leaderboard Response Object - * @throws IOException - */ - private LeaderboardResponse sendRequest(OkHttpClient okHttpClient, HttpUrl httpUrl) - throws IOException { - Request request = new Builder().url(httpUrl).build(); - Response response = okHttpClient.newCall(request).execute(); - if (response.isSuccessful()) { - Gson gson = new Gson(); - return gson.fromJson(response.body().string(), LeaderboardResponse.class); - } - return null; - } - - /** - * This method shuts down the Mock Server - * @throws IOException - */ - @After - public void shutdown() throws IOException { - server.shutdown(); - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.kt new file mode 100644 index 0000000000..ac0da42f35 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/LeaderboardApiTest.kt @@ -0,0 +1,121 @@ +package fr.free.nrw.commons.leaderboard + +import com.google.gson.Gson +import fr.free.nrw.commons.profile.leaderboard.LeaderboardResponse +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader + +/** + * This class tests the Leaderboard API calls + */ +class LeaderboardApiTest { + lateinit var server: MockWebServer + + /** + * This method initialises a Mock Server + */ + @Before + fun initTest() { + server = MockWebServer() + } + + /** + * This method will setup a Mock Server and load Test JSON Response File + * @throws Exception + */ + @Before + @Throws(Exception::class) + fun setUp() { + val testResponseBody = convertStreamToString( + javaClass.classLoader!!.getResourceAsStream(FILE_NAME) + ) + + server.enqueue(MockResponse().setBody(testResponseBody)) + server.start() + } + + /** + * This method will call the Mock Server and Test it with sample values. + * It will test the Leaderboard API call functionality and check if the object is + * being created with the correct values + * @throws IOException + */ + @Test + @Throws(IOException::class) + fun apiTest() { + val httpUrl = server.url(ENDPOINT) + val response = sendRequest(OkHttpClient(), httpUrl) + + Assert.assertEquals(TEST_AVATAR, response!!.avatar) + Assert.assertEquals(TEST_USERNAME, response.username) + Assert.assertEquals(TEST_USER_RANK, response.rank) + Assert.assertEquals(TEST_USER_COUNT, response.categoryCount) + } + + /** + * This method will call the Mock API and returns the Leaderboard Response Object + * @param okHttpClient + * @param httpUrl + * @return Leaderboard Response Object + * @throws IOException + */ + @Throws(IOException::class) + private fun sendRequest(okHttpClient: OkHttpClient, httpUrl: HttpUrl): LeaderboardResponse? { + val request: Request = Request.Builder().url(httpUrl).build() + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val gson = Gson() + return gson.fromJson(response.body!!.string(), LeaderboardResponse::class.java) + } + return null + } + + /** + * This method shuts down the Mock Server + * @throws IOException + */ + @After + @Throws(IOException::class) + fun shutdown() { + server.shutdown() + } + + companion object { + private const val TEST_USERNAME = "user" + private const val TEST_AVATAR = "avatar" + private const val TEST_USER_RANK = 1 + private const val TEST_USER_COUNT = 0 + + private const val FILE_NAME = "leaderboard_sample_response.json" + private const val ENDPOINT = "/leaderboard.py" + + /** + * This method converts a Input Stream to String + * @param is takes Input Stream of JSON File as Parameter + * @return a String with JSON data + * @throws Exception + */ + @Throws(Exception::class) + private fun convertStreamToString(`is`: InputStream): String { + val reader = BufferedReader(InputStreamReader(`is`)) + val sb = StringBuilder() + var line: String? + while ((reader.readLine().also { line = it }) != null) { + sb.append(line).append("\n") + } + reader.close() + return sb.toString() + } + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.java b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.java deleted file mode 100644 index 7c2b25d3b6..0000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package fr.free.nrw.commons.leaderboard; - -import com.google.gson.Gson; -import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Request.Builder; -import okhttp3.Response; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -public class UpdateAvatarApiTest { - - private static final String TEST_USERNAME = "user"; - private static final String TEST_STATUS = "200"; - private static final String TEST_MESSAGE = "Avatar Updated"; - private static final String FILE_NAME = "update_leaderboard_avatar_sample_response.json"; - private static final String ENDPOINT = "/update_avatar.py"; - MockWebServer server; - - /** - * This method converts a Input Stream to String - * - * @param is takes Input Stream of JSON File as Parameter - * @return a String with JSON data - * @throws Exception - */ - private static String convertStreamToString(final InputStream is) throws Exception { - final BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - final StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - /** - * This method initialises a Mock Server - */ - @Before - public void initTest() { - server = new MockWebServer(); - } - - /** - * This method will setup a Mock Server and load Test JSON Response File - * - * @throws Exception - */ - @Before - public void setUp() throws Exception { - - final String testResponseBody = convertStreamToString( - getClass().getClassLoader().getResourceAsStream(FILE_NAME)); - - server.enqueue(new MockResponse().setBody(testResponseBody)); - server.start(); - } - - /** - * This method will call the Mock Server and Test it with sample values. It will test the Update - * Avatar API call functionality and check if the object is being created with the correct - * values - * - * @throws IOException - */ - @Test - public void apiTest() throws IOException { - final HttpUrl httpUrl = server.url(ENDPOINT); - final UpdateAvatarResponse response = sendRequest(new OkHttpClient(), httpUrl); - - Assert.assertEquals(TEST_USERNAME, response.getUser()); - Assert.assertEquals(TEST_STATUS, response.getStatus()); - Assert.assertEquals(TEST_MESSAGE, response.getMessage()); - } - - /** - * This method will call the Mock API and returns the Update Avatar Response Object - * - * @param okHttpClient - * @param httpUrl - * @return Update Avatar Response Object - * @throws IOException - */ - private UpdateAvatarResponse sendRequest(final OkHttpClient okHttpClient, final HttpUrl httpUrl) - throws IOException { - final Request request = new Builder().url(httpUrl).build(); - final Response response = okHttpClient.newCall(request).execute(); - if (response.isSuccessful()) { - final Gson gson = new Gson(); - return gson.fromJson(response.body().string(), UpdateAvatarResponse.class); - } - return null; - } - - /** - * This method shuts down the Mock Server - * - * @throws IOException - */ - @After - public void shutdown() throws IOException { - server.shutdown(); - } -} - diff --git a/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.kt new file mode 100644 index 0000000000..6b7f064cf4 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/leaderboard/UpdateAvatarApiTest.kt @@ -0,0 +1,127 @@ +package fr.free.nrw.commons.leaderboard + +import com.google.gson.Gson +import fr.free.nrw.commons.profile.leaderboard.UpdateAvatarResponse +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader + +class UpdateAvatarApiTest { + lateinit var server: MockWebServer + + /** + * This method initialises a Mock Server + */ + @Before + fun initTest() { + server = MockWebServer() + } + + /** + * This method will setup a Mock Server and load Test JSON Response File + * + * @throws Exception + */ + @Before + @Throws(Exception::class) + fun setUp() { + val testResponseBody = convertStreamToString( + javaClass.classLoader!!.getResourceAsStream(FILE_NAME) + ) + + server.enqueue(MockResponse().setBody(testResponseBody)) + server.start() + } + + /** + * This method will call the Mock Server and Test it with sample values. It will test the Update + * Avatar API call functionality and check if the object is being created with the correct + * values + * + * @throws IOException + */ + @Test + @Throws(IOException::class) + fun apiTest() { + val httpUrl = server.url(ENDPOINT) + val response = sendRequest(OkHttpClient(), httpUrl) + Assert.assertNotNull(response) + + with(response!!) { + Assert.assertEquals(TEST_USERNAME, user) + Assert.assertEquals(TEST_STATUS, status) + Assert.assertEquals(TEST_MESSAGE, message) + } + } + + /** + * This method will call the Mock API and returns the Update Avatar Response Object + * + * @param okHttpClient + * @param httpUrl + * @return Update Avatar Response Object + * @throws IOException + */ + @Throws(IOException::class) + private fun sendRequest(okHttpClient: OkHttpClient, httpUrl: HttpUrl): UpdateAvatarResponse? { + val request: Request = Request.Builder().url(httpUrl).build() + val response = okHttpClient.newCall(request).execute() + if (response.isSuccessful) { + val gson = Gson() + return gson.fromJson( + response.body!!.string(), + UpdateAvatarResponse::class.java + ) + } + return null + } + + /** + * This method shuts down the Mock Server + * + * @throws IOException + */ + @After + @Throws(IOException::class) + fun shutdown() { + server.shutdown() + } + + companion object { + private const val TEST_USERNAME = "user" + private const val TEST_STATUS = "200" + private const val TEST_MESSAGE = "Avatar Updated" + private const val FILE_NAME = "update_leaderboard_avatar_sample_response.json" + private const val ENDPOINT = "/update_avatar.py" + + /** + * This method converts a Input Stream to String + * + * @param is takes Input Stream of JSON File as Parameter + * @return a String with JSON data + * @throws Exception + */ + @Throws(Exception::class) + private fun convertStreamToString(`is`: InputStream): String { + val reader = BufferedReader(InputStreamReader(`is`)) + val sb = StringBuilder() + var line: String? + while ((reader.readLine().also { line = it }) != null) { + sb.append(line).append("\n") + } + reader.close() + return sb.toString() + } + } +} +