From 7400872f872c678c28d5a26f3300214734b68176 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Mon, 27 Jan 2025 13:01:31 +0100 Subject: [PATCH 01/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-diq/strings.xml | 2 ++ app/src/main/res/values-iw/strings.xml | 1 + app/src/main/res/values-ps/strings.xml | 1 + app/src/main/res/values-sv/strings.xml | 1 + 4 files changed, 5 insertions(+) diff --git a/app/src/main/res/values-diq/strings.xml b/app/src/main/res/values-diq/strings.xml index 836e8ef62..cf48861d0 100644 --- a/app/src/main/res/values-diq/strings.xml +++ b/app/src/main/res/values-diq/strings.xml @@ -349,4 +349,6 @@ Tayêna bıwane Şınasnayış Raçarne + Hesab + Bınnuşte diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index fd3bbb163..61381c407 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -851,4 +851,5 @@ אזהרת העלמת חשבון כותרת הכותרת הועתקה ללוח + ברכותינו, כל התמונות באלבום הזה הועלו או שסומנו לא להעלאה. diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml index 4f17da26f..ca52b10d8 100644 --- a/app/src/main/res/values-ps/strings.xml +++ b/app/src/main/res/values-ps/strings.xml @@ -88,4 +88,5 @@ امستنې غبرگون وتل + گڼون diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index be06b0b99..371744b44 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -820,4 +820,5 @@ Att få kontot att försvinna är en <b>sista utväg</b> och bör <b>endast användas när du vill sluta redigera för alltid</b> och även dölja så många av dina tidigare associationer som möjligt.<br/><br/>Konton raderas på Wikimedia Commons genom att ändra kontonamnet för att göra så att andra inte kan känna igen bidragen i en process som kallas kontoförsvinnande. <b>Försvinnande garanterar inte fullständig anonymitet eller att bidrag tas bort från projekten</b>. Bildtext Bildtext kopierades till urklipp + Grattis! Alla bilder i detta album har antingen laddats upp eller markerats för att inte laddas upp. From 41170d81d91a0da638f9ba38f3ecdd99ff7100fa Mon Sep 17 00:00:00 2001 From: yuvraj-coder1 <142040957+yuvraj-coder1@users.noreply.github.com> Date: Mon, 27 Jan 2025 18:09:32 +0530 Subject: [PATCH 02/22] fix: logout user after account deletion by navigating to login screen (#6159) --- .../free/nrw/commons/activity/SingleWebViewActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt index 0583ae2f9..b7951adab 100644 --- a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener import fr.free.nrw.commons.R import fr.free.nrw.commons.di.ApplicationlessInjection import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar @@ -85,7 +87,12 @@ class SingleWebViewActivity : ComponentActivity() { url = url, successUrl = successUrl, onSuccess = { - // TODO Redirect the user to login screen like we do when the user logout's + //Redirect the user to login screen like we do when the user logout's + val app = applicationContext as CommonsApplication + app.clearApplicationData( + applicationContext, + ActivityLogoutListener(activity = this, ctx = applicationContext) + ) finish() }, modifier = Modifier From e01ecb20fa4dfdfdd53f6ef6d4f9facd718c7060 Mon Sep 17 00:00:00 2001 From: Tanmay Gupta <119003089+savsch@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:06:35 +0530 Subject: [PATCH 03/22] maps intent: preserve zoom and show red pin (#6160) Co-authored-by: Nicolas Raoul --- .../main/java/fr/free/nrw/commons/Utils.java | 26 ++++++++++++++----- .../explore/map/ExploreMapFragment.java | 5 ++-- .../fr/free/nrw/commons/location/LatLng.kt | 9 ++++--- .../locationpicker/LocationPickerActivity.kt | 6 ++++- .../fragments/NearbyParentFragment.java | 3 ++- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java index cd9c6eed5..8d0f8b530 100644 --- a/app/src/main/java/fr/free/nrw/commons/Utils.java +++ b/app/src/main/java/fr/free/nrw/commons/Utils.java @@ -148,13 +148,27 @@ public class Utils { } /** - * Util function to handle geo coordinates - * It no longer depends on google maps and any app capable of handling the map intent can handle it - * @param context - * @param latLng + * Util function to handle geo coordinates. It no longer depends on google maps and any app + * capable of handling the map intent can handle it + * + * @param context The context for launching intent + * @param latLng The latitude and longitude of the location */ - public static void handleGeoCoordinates(Context context, LatLng latLng) { - Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri()); + public static void handleGeoCoordinates(final Context context, final LatLng latLng) { + handleGeoCoordinates(context, latLng, 16); + } + + /** + * Util function to handle geo coordinates with specified zoom level. It no longer depends on + * google maps and any app capable of handling the map intent can handle it + * + * @param context The context for launching intent + * @param latLng The latitude and longitude of the location + * @param zoomLevel The zoom level + */ + public static void handleGeoCoordinates(final Context context, final LatLng latLng, + final double zoomLevel) { + final Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri(zoomLevel)); if (mapIntent.resolveActivity(context.getPackageManager()) != null) { context.startActivity(mapIntent); } else { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index fd1ea1f28..ad42d9518 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -545,8 +545,9 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment * @param place Place of clicked nearby marker */ private void passInfoToSheet(final Place place) { - binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener(view -> Utils.handleGeoCoordinates(getActivity(), - place.getLocation())); + binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener( + view -> Utils.handleGeoCoordinates(getActivity(), + place.getLocation(), binding.mapView.getZoomLevelDouble())); binding.bottomSheetDetailsBinding.commonsButton.setVisibility(place.hasCommonsLink() ? View.VISIBLE : View.GONE); binding.bottomSheetDetailsBinding.commonsButton.setOnClickListener( 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 index 7dd9a49ce..5ca75b38c 100644 --- a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt +++ b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt @@ -123,10 +123,13 @@ data class LatLng( /** * Gets a URI for a Google Maps intent at the location. + * + * @paraam zoom The zoom level + * @return URI for the intent */ - fun getGmmIntentUri(): Uri { - return Uri.parse("geo:$latitude,$longitude?z=16") - } + fun getGmmIntentUri(zoom: Double): Uri = Uri.parse( + "geo:$latitude,$longitude?q=$latitude,$longitude&z=${zoom}" + ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeDouble(latitude) 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 080bc058d..2a7b7713b 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 @@ -430,7 +430,11 @@ class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { else -> null } - position?.let { Utils.handleGeoCoordinates(this, it) } + position?.let { + mapView?.zoomLevelDouble?.let { zoomLevel -> + Utils.handleGeoCoordinates(this, it, zoomLevel) + } ?: Utils.handleGeoCoordinates(this, it) + } } /** diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index f3224de7f..ee15d13ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -2309,7 +2309,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment updateBookmarkButtonImage(selectedPlace); break; case R.drawable.ic_directions_black_24dp: - Utils.handleGeoCoordinates(this.getContext(), selectedPlace.getLocation()); + Utils.handleGeoCoordinates(this.getContext(), selectedPlace.getLocation(), + binding.map.getZoomLevelDouble()); break; case R.drawable.ic_wikidata_logo_24dp: Utils.handleWebUrl(this.getContext(), selectedPlace.siteLinks.getWikidataLink()); From 36f844a709dc40944644723917c0b1622380edf6 Mon Sep 17 00:00:00 2001 From: Sujal Date: Wed, 29 Jan 2025 19:20:29 +0530 Subject: [PATCH 04/22] Show placeholder and display depiction section when no depictions are available (#6163) (#6165) * corrected * corrected * Update MediaDetailFragment.kt Spelling correction --- .../nrw/commons/media/MediaDetailFragment.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index 4c993fb80..d714b094a 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -650,10 +650,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } private fun onDepictionsLoaded(idAndCaptions: List) { - binding.depictsLayout.visibility = - if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE - binding.depictionsEditButton.visibility = - if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + binding.depictsLayout.visibility = View.VISIBLE + binding.depictionsEditButton.visibility = View.VISIBLE buildDepictionList(idAndCaptions) } @@ -863,8 +861,22 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C */ private fun buildDepictionList(idAndCaptions: List) { binding.mediaDetailDepictionContainer.removeAllViews() + + // Create a mutable list from the original list + val mutableIdAndCaptions = idAndCaptions.toMutableList() + + if (mutableIdAndCaptions.isEmpty()) { + // Create a placeholder IdAndCaptions object and add it to the list + mutableIdAndCaptions.add( + IdAndCaptions( + id = media?.pageId ?: "", // Use an empty string if media?.pageId is null + captions = mapOf(Locale.getDefault().language to getString(R.string.detail_panel_cats_none)) // Create a Map with the language as the key and the message as the value + ) + ) + } + val locale: String = Locale.getDefault().language - for (idAndCaption: IdAndCaptions in idAndCaptions) { + for (idAndCaption: IdAndCaptions in mutableIdAndCaptions) { binding.mediaDetailDepictionContainer.addView( buildDepictLabel( getDepictionCaption(idAndCaption, locale), @@ -875,6 +887,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C } } + private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { // Check if the Depiction Caption is available in user's locale // if not then check for english, else show any available. From 5d4474ead65649377929f8fc37a50521afb30767 Mon Sep 17 00:00:00 2001 From: Akshay Komar <146421342+Akshaykomar890@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:11:27 +0530 Subject: [PATCH 05/22] Migrated AboutActivity from Java to Kotlin (#6158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename Constants to Follow Kotlin Naming Conventions >This PR refactors constant names in the project to adhere to Kotlin's UPPERCASE_SNAKE_CASE naming convention, improving code readability and maintaining consistency across the codebase. >Renamed the following constants in LoginActivity: >saveProgressDialog → SAVE_PROGRESS_DIALOG >saveErrorMessage → SAVE_ERROR_MESSAGE >saveUsername → SAVE_USERNAME >savePassword → SAVE_PASSWORD >Updated all references to these constants throughout the project. * Update Project_Default.xml * Refactor variable names to adhere to naming conventions Renamed variables to use camel case: -UPLOAD_COUNT_THRESHOLD → uploadCountThreshold -REVERT_PERCENTAGE_FOR_MESSAGE → revertPercentageForMessage -REVERT_SHARED_PREFERENCE → revertSharedPreference -UPLOAD_SHARED_PREFERENCE → uploadSharedPreference Renamed variables with uppercase initials to lowercase for alignment with Kotlin conventions: -Latitude → latitude -Longitude → longitude -Accuracy → accuracy Refactored the following variable names: -NUMBER_OF_QUESTIONS → numberOfQuestions -MULTIPLIER_TO_GET_PERCENTAGE → multiplierToGetPercentage * Refactor Dialog View Initialization with Null-Safe Calls This PR refactors the dialog setup code in CustomSelectorActivity to improve safety and readability by replacing explicit casts with null-safe generic calls for findViewById. >Replaced explicit casting (as Button and as TextView) with the generic findViewById() method for improved type safety. >Added null-safety (?.) to avoid potential crashes if a view is not found in the dialog layout. why changed:- >Prevents runtime crashes caused by NullPointerException when a view is missing in the layout. * Refactor Unit Test: Replace Unsafe Casting with Type-Safe Mocking for findViewById >PR refactors the unit test for NearbyParentFragment by replacing unsafe casting in the findViewById mocking statements with type-safe >Ensured all findViewById mocks now use a consistent, type-safe format (findViewById(...)) to reduce verbosity and potential casting errors. >Verified the functionality of prepareViewsForSheetPosition remains unchanged, ensuring no regression in test behavior. * Update NearbyParentFragmentUnitTest.kt * Refactor: Rename Constants to Follow CamelCase Naming Convention >Updated all constant variable names to follow the camelCase naming convention, removing underscores in the middle or end. >Ensured variable names remain descriptive and align with code readability best practices. * Replace private val with const val for URL constants in QuizController * Renaming the constant to use UPPER_SNAKE_CASE * Renaming the constant to use UPPER_SNAKE_CASE * Update Done * **Refactor: Convert `minimumThresholdForSwipe` to a compile-time constant** * Convert AboutActivity from Java to Kotlin This PR converts the AboutActivity class from Java to Kotlin >Testing: >Verified all functionalities of the AboutActivity, including toolbar setup, intent launches, and dialog interactions, to ensure behavior remains consistent post-conversion. >Successfully ran unit tests for AboutActivity to confirm the correctness of methods and logic. * Thank you for the suggestion! Since these methods all take a single View parameter, replacing them with method references is a great way to simplify the code and improve readability. I'll updated the code accordingly. Added a TODO in the code as a reminder to refactor this in the future. --------- Co-authored-by: Nicolas Raoul --- .../fr/free/nrw/commons/AboutActivity.java | 187 ---------------- .../java/fr/free/nrw/commons/AboutActivity.kt | 209 ++++++++++++++++++ 2 files changed, 209 insertions(+), 187 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/AboutActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/AboutActivity.kt diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java b/app/src/main/java/fr/free/nrw/commons/AboutActivity.java deleted file mode 100644 index dcc9bfd43..000000000 --- a/app/src/main/java/fr/free/nrw/commons/AboutActivity.java +++ /dev/null @@ -1,187 +0,0 @@ -package fr.free.nrw.commons; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.LinearLayout; -import android.widget.Spinner; -import androidx.annotation.NonNull; -import fr.free.nrw.commons.databinding.ActivityAboutBinding; -import fr.free.nrw.commons.theme.BaseActivity; -import fr.free.nrw.commons.utils.ConfigUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import java.util.Collections; -import java.util.List; - -/** - * Represents about screen of this app - */ -public class AboutActivity extends BaseActivity { - - /* - This View Binding class is auto-generated for each xml file. The format is usually the name - of the file with PascalCasing (The underscore characters will be ignored). - More information is available at https://developer.android.com/topic/libraries/view-binding - */ - private ActivityAboutBinding binding; - - /** - * This method helps in the creation About screen - * - * @param savedInstanceState Data bundle - */ - @Override - @SuppressLint("StringFormatInvalid") - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - /* - Instead of just setting the view with the xml file. We need to use View Binding class. - */ - binding = ActivityAboutBinding.inflate(getLayoutInflater()); - final View view = binding.getRoot(); - setContentView(view); - - setSupportActionBar(binding.toolbarBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - final String aboutText = getString(R.string.about_license); - /* - We can then access all the views by just using the id names like this. - camelCasing is used with underscore characters being ignored. - */ - binding.aboutLicense.setHtmlText(aboutText); - - @SuppressLint("StringFormatMatches") - String improveText = String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL); - binding.aboutImprove.setHtmlText(improveText); - binding.aboutVersion.setText(ConfigUtils.getVersionNameWithSha(getApplicationContext())); - - Utils.setUnderlinedText(binding.aboutFaq, R.string.about_faq, getApplicationContext()); - Utils.setUnderlinedText(binding.aboutRateUs, R.string.about_rate_us, getApplicationContext()); - Utils.setUnderlinedText(binding.aboutUserGuide, R.string.user_guide, getApplicationContext()); - Utils.setUnderlinedText(binding.aboutPrivacyPolicy, R.string.about_privacy_policy, getApplicationContext()); - Utils.setUnderlinedText(binding.aboutTranslate, R.string.about_translate, getApplicationContext()); - Utils.setUnderlinedText(binding.aboutCredits, R.string.about_credits, getApplicationContext()); - - /* - To set listeners, we can create a separate method and use lambda syntax. - */ - binding.facebookLaunchIcon.setOnClickListener(this::launchFacebook); - binding.githubLaunchIcon.setOnClickListener(this::launchGithub); - binding.websiteLaunchIcon.setOnClickListener(this::launchWebsite); - binding.aboutRateUs.setOnClickListener(this::launchRatings); - binding.aboutCredits.setOnClickListener(this::launchCredits); - binding.aboutPrivacyPolicy.setOnClickListener(this::launchPrivacyPolicy); - binding.aboutUserGuide.setOnClickListener(this::launchUserGuide); - binding.aboutFaq.setOnClickListener(this::launchFrequentlyAskedQuesions); - binding.aboutTranslate.setOnClickListener(this::launchTranslate); - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } - - public void launchFacebook(View view) { - Intent intent; - try { - intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL)); - intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME); - startActivity(intent); - } catch (Exception e) { - Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL)); - } - } - - public void launchGithub(View view) { - Intent intent; - try { - intent = new Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL)); - intent.setPackage(Urls.GITHUB_PACKAGE_NAME); - startActivity(intent); - } catch (Exception e) { - Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL)); - } - } - - public void launchWebsite(View view) { - Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL)); - } - - public void launchRatings(View view){ - Utils.rateApp(this); - } - - public void launchCredits(View view) { - Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL)); - } - - public void launchUserGuide(View view) { - Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL)); - } - - public void launchPrivacyPolicy(View view) { - Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)); - } - - public void launchFrequentlyAskedQuesions(View view) { - Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL)); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_about, menu); - return super.onCreateOptionsMenu(menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.share_app_icon: - String shareText = String.format(getString(R.string.share_text), Urls.PLAY_STORE_URL_PREFIX + this.getPackageName()); - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, shareText); - sendIntent.setType("text/plain"); - startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via))); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void launchTranslate(View view) { - @NonNull List sortedLocalizedNamesRef = CommonsApplication.getInstance().getLanguageLookUpTable().getCanonicalNames(); - Collections.sort(sortedLocalizedNamesRef); - final ArrayAdapter languageAdapter = new ArrayAdapter<>(AboutActivity.this, - android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef); - final Spinner spinner = new Spinner(AboutActivity.this); - spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - spinner.setAdapter(languageAdapter); - spinner.setGravity(17); - spinner.setPadding(50,0,0,0); - - Runnable positiveButtonRunnable = () -> { - String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition()); - Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode)); - }; - DialogUtil.showAlertDialog(this, - getString(R.string.about_translate_title), - getString(R.string.about_translate_message), - getString(R.string.about_translate_proceed), - getString(R.string.about_translate_cancel), - positiveButtonRunnable, - () -> {}, - spinner - ); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt new file mode 100644 index 000000000..143f5e569 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt @@ -0,0 +1,209 @@ +package fr.free.nrw.commons + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.Spinner +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.databinding.ActivityAboutBinding +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import java.util.Collections + +/** + * Represents about screen of this app + */ +class AboutActivity : BaseActivity() { + /* + This View Binding class is auto-generated for each xml file. The format is usually the name + of the file with PascalCasing (The underscore characters will be ignored). + More information is available at https://developer.android.com/topic/libraries/view-binding + */ + private var binding: ActivityAboutBinding? = null + + /** + * This method helps in the creation About screen + * + * @param savedInstanceState Data bundle + */ + @SuppressLint("StringFormatInvalid") //TODO: + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /* + Instead of just setting the view with the xml file. We need to use View Binding class. + */ + binding = ActivityAboutBinding.inflate(layoutInflater) + val view: View = binding!!.root + setContentView(view) + + setSupportActionBar(binding!!.toolbarBinding.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + val aboutText = getString(R.string.about_license) + /* + We can then access all the views by just using the id names like this. + camelCasing is used with underscore characters being ignored. + */ + binding!!.aboutLicense.setHtmlText(aboutText) + + @SuppressLint("StringFormatMatches") // TODO: + val improveText = + String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL) + binding!!.aboutImprove.setHtmlText(improveText) + binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha() + + Utils.setUnderlinedText( + binding!!.aboutFaq, R.string.about_faq, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutRateUs, R.string.about_rate_us, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutUserGuide, R.string.user_guide, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutPrivacyPolicy, R.string.about_privacy_policy, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutTranslate, R.string.about_translate, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutCredits, R.string.about_credits, + applicationContext + ) + + /* + To set listeners, we can create a separate method and use lambda syntax. + */ + binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook) + binding!!.githubLaunchIcon.setOnClickListener(::launchGithub) + binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite) + binding!!.aboutRateUs.setOnClickListener(::launchRatings) + binding!!.aboutCredits.setOnClickListener(::launchCredits) + binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy) + binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide) + binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions) + binding!!.aboutTranslate.setOnClickListener(::launchTranslate) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + fun launchFacebook(view: View?) { + val intent: Intent + try { + intent = Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL)) + intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL)) + } + } + + fun launchGithub(view: View?) { + val intent: Intent + try { + intent = Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL)) + intent.setPackage(Urls.GITHUB_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL)) + } + } + + fun launchWebsite(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL)) + } + + fun launchRatings(view: View?) { + Utils.rateApp(this) + } + + fun launchCredits(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL)) + } + + fun launchUserGuide(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL)) + } + + fun launchPrivacyPolicy(view: View?) { + Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)) + } + + fun launchFrequentlyAskedQuesions(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL)) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_about, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.share_app_icon -> { + val shareText = String.format( + getString(R.string.share_text), + Urls.PLAY_STORE_URL_PREFIX + this.packageName + ) + val sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.putExtra(Intent.EXTRA_TEXT, shareText) + sendIntent.setType("text/plain") + startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via))) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun launchTranslate(view: View?) { + val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames() + Collections.sort(sortedLocalizedNamesRef) + val languageAdapter = ArrayAdapter( + this@AboutActivity, + android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef + ) + val spinner = Spinner(this@AboutActivity) + spinner.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + spinner.setPadding(50, 0, 0, 0) + + val positiveButtonRunnable = Runnable { + val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition] + Utils.handleWebUrl(this@AboutActivity, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode)) + } + showAlertDialog( + this, + getString(R.string.about_translate_title), + getString(R.string.about_translate_message), + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + positiveButtonRunnable, + {}, + spinner + ) + } +} From 9dc9a3b8abc696e46d941b7ea1046c1a9e3f7cbd Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Thu, 30 Jan 2025 13:01:33 +0100 Subject: [PATCH 06/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-ar/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2b54dfaa6..f3b7d8467 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -20,6 +20,7 @@ * NancyMilad * OsamaK * Tala Ali +* XIDME * أيوب * أَحمد * ترجمان05 @@ -408,7 +409,7 @@ خطأ في جلب المعالم القريبة. لا توجد عمليات بحث حديثة هل أنت متأكد من أنك تريد مسح سجل بحثك؟ - هل انت متأكد انك تريد الغاء هذا التحميل + هل أنت متأكد أنك تريد إلغاء هذا التحميل؟ هل تريد حذف هذا البحث؟ تم حذف سجل البحث ترشيح للحذف From 7b291535e0a16e16ba1455a87cff97fc2b07af87 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Andrew Omole Date: Thu, 30 Jan 2025 13:58:00 +0100 Subject: [PATCH 07/22] Feat: Make it smoother to switch between nearby and explore maps (#6164) * Nearby: Add 'Show in Explore' 3-dots menu item * MainActivity: Add methods to pass extras between Nearby and Explore * MainActivity: Extend loadFragment() to support passing fragment arguments * Nearby: Add ability to navigate to Explore fragment on 'Show in Explore' click * Explore: Read fragment arguments for Nearby map data and update Explore map if present * Explore: Add 'Show in Nearby' 3-dots menu item. Only visible when Map tab is selected * Explore: On 'Show in Nearby' click, navigate to Nearby fragment, passing map data as fragment args * Nearby: Read fragment arguments for Explore map data and update Nearby map if present * MainActivity: Fix memory leaks when navigating between bottom nav destinations * Explore: Fix crashes caused by unattached map fragment * Refactor code to pass unit tests * Explore: Format javadocs --------- Co-authored-by: Nicolas Raoul --- .gitignore | 3 +- .../commons/contributions/MainActivity.java | 64 ++++ .../nrw/commons/explore/ExploreFragment.java | 85 ++++- .../explore/ExploreMapRootFragment.java | 19 +- .../explore/map/ExploreMapFragment.java | 318 +++++++++++------- .../fragments/NearbyParentFragment.java | 132 ++++++-- .../main/res/menu/explore_fragment_menu.xml | 19 ++ .../main/res/menu/nearby_fragment_menu.xml | 6 + app/src/main/res/values/strings.xml | 2 + .../explore/ExploreFragmentUnitTest.kt | 14 +- 10 files changed, 510 insertions(+), 152 deletions(-) create mode 100644 app/src/main/res/menu/explore_fragment_menu.xml diff --git a/.gitignore b/.gitignore index e54ea2551..7fa4767a7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ captures/* # Test and other output app/jacoco.exec -app/CommonsContributions \ No newline at end of file +app/CommonsContributions +app/.* diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java index 03027f287..047943721 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java @@ -207,6 +207,9 @@ public class MainActivity extends BaseActivity private boolean loadFragment(Fragment fragment, boolean showBottom) { //showBottom so that we do not show the bottom tray again when constructing //from the saved instance state. + + freeUpFragments(); + if (fragment instanceof ContributionsFragment) { if (activeFragment == ActiveFragment.CONTRIBUTIONS) { // scroll to top if already on the Contributions tab @@ -256,6 +259,31 @@ public class MainActivity extends BaseActivity return false; } + /** + * loadFragment() overload that supports passing extras to fragments + **/ + private boolean loadFragment(Fragment fragment, boolean showBottom, Bundle args) { + if (fragment != null && args != null) { + fragment.setArguments(args); + } + + return loadFragment(fragment, showBottom); + } + + /** + * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding + * references to cleared fragments. This function frees up all fragment references. + *

+ * Called in loadFragment() before doing the actual loading. + **/ + public void freeUpFragments() { + // free all fragments except contributionsFragment because several tests depend on it. + // hence, contributionsFragment is probably still a leak + nearbyParentFragment = null; + exploreFragment = null; + bookmarkFragment = null; + } + public void hideTabs() { binding.fragmentMainNavTabLayout.setVisibility(View.GONE); } @@ -432,6 +460,42 @@ public class MainActivity extends BaseActivity }); } + /** + * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks + * the 'Show in Explore' option in the 3-dots menu in Nearby. + * + * @param zoom current zoom of Nearby map + * @param latitude current latitude of Nearby map + * @param longitude current longitude of Nearby map + **/ + public void loadExploreMapFromNearby(double zoom, double latitude, double longitude) { + Bundle bundle = new Bundle(); + bundle.putDouble("prev_zoom", zoom); + bundle.putDouble("prev_latitude", latitude); + bundle.putDouble("prev_longitude", longitude); + + loadFragment(ExploreFragment.newInstance(), false, bundle); + setSelectedItemId(NavTab.EXPLORE.code()); + } + + /** + * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks + * the 'Show in Nearby' option in the 3-dots menu in Explore. + * + * @param zoom current zoom of Explore map + * @param latitude current latitude of Explore map + * @param longitude current longitude of Explore map + **/ + public void loadNearbyMapFromExplore(double zoom, double latitude, double longitude) { + Bundle bundle = new Bundle(); + bundle.putDouble("prev_zoom", zoom); + bundle.putDouble("prev_latitude", latitude); + bundle.putDouble("prev_longitude", longitude); + + loadFragment(NearbyParentFragment.newInstance(), false, bundle); + setSelectedItemId(NavTab.NEARBY.code()); + } + @Override protected void onResume() { super.onResume(); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java index d444148d4..223d028dc 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java @@ -1,5 +1,7 @@ package fr.free.nrw.commons.explore; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; + import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; @@ -42,9 +44,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { @Named("default_preferences") public JsonKvStore applicationKvStore; - public void setScroll(boolean canScroll){ - if (binding != null) - { + // Nearby map state (for if we came from Nearby fragment) + private double prevZoom; + private double prevLatitude; + private double prevLongitude; + + public void setScroll(boolean canScroll) { + if (binding != null) { binding.viewPager.setCanScroll(canScroll); } } @@ -60,6 +66,7 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + loadNearbyMapData(); binding = FragmentExploreBinding.inflate(inflater, container, false); viewPagerAdapter = new ViewPagerAdapter(getChildFragmentManager()); @@ -89,6 +96,11 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { }); setTabs(); setHasOptionsMenu(true); + + // if we came from 'Show in Explore' in Nearby, jump to Map tab + if (isCameFromNearbyMap()) { + binding.viewPager.setCurrentItem(2); + } return binding.getRoot(); } @@ -108,6 +120,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { Bundle mapArguments = new Bundle(); mapArguments.putString("categoryName", EXPLORE_MAP); + // if we came from 'Show in Explore' in Nearby, pass on zoom and center to Explore map root + if (isCameFromNearbyMap()) { + mapArguments.putDouble("prev_zoom", prevZoom); + mapArguments.putDouble("prev_latitude", prevLatitude); + mapArguments.putDouble("prev_longitude", prevLongitude); + } + featuredRootFragment = new ExploreListRootFragment(featuredArguments); mobileRootFragment = new ExploreListRootFragment(mobileArguments); mapRootFragment = new ExploreMapRootFragment(mapArguments); @@ -120,13 +139,35 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { fragmentList.add(mapRootFragment); titleList.add(getString(R.string.explore_tab_title_map).toUpperCase(Locale.ROOT)); - ((MainActivity)getActivity()).showTabs(); + ((MainActivity) getActivity()).showTabs(); ((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false); viewPagerAdapter.setTabData(fragmentList, titleList); viewPagerAdapter.notifyDataSetChanged(); } + /** + * Fetch Nearby map camera data from fragment arguments if any. + */ + public void loadNearbyMapData() { + // get fragment arguments + if (getArguments() != null) { + prevZoom = getArguments().getDouble("prev_zoom"); + prevLatitude = getArguments().getDouble("prev_latitude"); + prevLongitude = getArguments().getDouble("prev_longitude"); + } + } + + /** + * Checks if fragment arguments contain data from Nearby map. if present, then the user + * navigated from Nearby using 'Show in Explore'. + * + * @return true if user navigated from Nearby map + **/ + public boolean isCameFromNearbyMap() { + return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0; + } + public boolean onBackPressed() { if (binding.tabLayout.getSelectedTabPosition() == 0) { if (featuredRootFragment.backPressed()) { @@ -155,7 +196,38 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { */ @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_search, menu); + // if logged in 'Show in Nearby' menu item is visible + if (applicationKvStore.getBoolean("login_skipped") == false) { + inflater.inflate(R.menu.explore_fragment_menu, menu); + + MenuItem others = menu.findItem(R.id.list_item_show_in_nearby); + + if (binding.viewPager.getCurrentItem() == 2) { + others.setVisible(true); + } + + // if on Map tab, show all menu options, else only show search + binding.viewPager.addOnPageChangeListener(new OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + others.setVisible((position == 2)); + } + + @Override + public void onPageScrollStateChanged(int state) { + if (state == SCROLL_STATE_IDLE && binding.viewPager.getCurrentItem() == 2) { + onPageSelected(2); + } + } + }); + } else { + inflater.inflate(R.menu.menu_search, menu); + } super.onCreateOptionsMenu(menu, inflater); } @@ -171,6 +243,9 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { case R.id.action_search: ActivityUtils.startActivityWithFlags(getActivity(), SearchActivity.class); return true; + case R.id.list_item_show_in_nearby: + mapRootFragment.loadNearbyMapFromExplore(); + return true; default: return super.onOptionsItemSelected(item); } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java index 2653b4409..abf02758d 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java @@ -39,10 +39,22 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme } public ExploreMapRootFragment(Bundle bundle) { + // get fragment arguments String title = bundle.getString("categoryName"); + double zoom = bundle.getDouble("prev_zoom"); + double latitude = bundle.getDouble("prev_latitude"); + double longitude = bundle.getDouble("prev_longitude"); + mapFragment = new ExploreMapFragment(); Bundle featuredArguments = new Bundle(); featuredArguments.putString("categoryName", title); + + // if we came from 'Show in Explore' in Nearby, pass on zoom and center + if (zoom != 0.0 || latitude != 0.0 || longitude != 0.0) { + featuredArguments.putDouble("prev_zoom", zoom); + featuredArguments.putDouble("prev_latitude", latitude); + featuredArguments.putDouble("prev_longitude", longitude); + } mapFragment.setArguments(featuredArguments); } @@ -198,7 +210,8 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme ((MainActivity) getActivity()).showTabs(); return true; - } if (mapFragment != null && mapFragment.isVisible()) { + } + if (mapFragment != null && mapFragment.isVisible()) { if (mapFragment.backButtonClicked()) { // Explore map fragment handled the event no further action required. return true; @@ -213,6 +226,10 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme return false; } + public void loadNearbyMapFromExplore() { + mapFragment.loadNearbyMapFromExplore(); + } + @Override public void onDestroy() { super.onDestroy(); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index ad42d9518..e64b96190 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -38,6 +38,7 @@ import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao; +import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.databinding.FragmentExploreMapBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.ExploreMapRootFragment; @@ -115,6 +116,11 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment SystemThemeUtils systemThemeUtils; LocationPermissionsHelper locationPermissionsHelper; + // Nearby map state (if we came from Nearby) + private double prevZoom; + private double prevLatitude; + private double prevLongitude; + private ExploreMapPresenter presenter; public FragmentExploreMapBinding binding; @@ -160,6 +166,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment ViewGroup container, Bundle savedInstanceState ) { + loadNearbyMapData(); binding = FragmentExploreMapBinding.inflate(getLayoutInflater()); return binding.getRoot(); } @@ -169,12 +176,14 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment super.onViewCreated(view, savedInstanceState); setSearchThisAreaButtonVisibility(false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); + binding.tvAttribution.setText( + Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); } else { binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); } initNetworkBroadCastReceiver(); - locationPermissionsHelper = new LocationPermissionsHelper(getActivity(),locationManager,this); + locationPermissionsHelper = new LocationPermissionsHelper(getActivity(), locationManager, + this); if (presenter == null) { presenter = new ExploreMapPresenter(bookmarkLocationDao); } @@ -204,9 +213,14 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment scaleBarOverlay.setBackgroundPaint(barPaint); scaleBarOverlay.enableScaleBar(); binding.mapView.getOverlays().add(scaleBarOverlay); - binding.mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); + binding.mapView.getZoomController() + .setVisibility(CustomZoomButtonsController.Visibility.NEVER); binding.mapView.setMultiTouchControls(true); - binding.mapView.getController().setZoom(ZOOM_LEVEL); + + if (!isCameFromNearbyMap()) { + binding.mapView.getController().setZoom(ZOOM_LEVEL); + } + performMapReadyActions(); binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { @@ -295,7 +309,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment unregisterNetworkReceiver(); } - + /** * Unregisters the networkReceiver */ @@ -328,11 +342,51 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment isPermissionDenied = true; } lastKnownLocation = MapUtils.getDefaultLatLng(); - moveCameraToPosition( - new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); + + // if we came from 'Show in Explore' in Nearby, load Nearby map center and zoom + if (isCameFromNearbyMap()) { + moveCameraToPosition( + new GeoPoint(prevLatitude, prevLongitude), + prevZoom, + 1L + ); + } else { + moveCameraToPosition( + new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); + } presenter.onMapReady(exploreMapController); } + /** + * Fetch Nearby map camera data from fragment arguments if any. + */ + public void loadNearbyMapData() { + // get fragment arguments + if (getArguments() != null) { + prevZoom = getArguments().getDouble("prev_zoom"); + prevLatitude = getArguments().getDouble("prev_latitude"); + prevLongitude = getArguments().getDouble("prev_longitude"); + } + } + + /** + * Checks if fragment arguments contain data from Nearby map, indicating that the user navigated + * from Nearby using 'Show in Explore'. + * + * @return true if user navigated from Nearby map + **/ + public boolean isCameFromNearbyMap() { + return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0; + } + + public void loadNearbyMapFromExplore() { + ((MainActivity) getContext()).loadNearbyMapFromExplore( + binding.mapView.getZoomLevelDouble(), + binding.mapView.getMapCenter().getLatitude(), + binding.mapView.getMapCenter().getLongitude() + ); + } + private void initViews() { Timber.d("init views called"); initBottomSheets(); @@ -346,7 +400,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment */ @SuppressLint("ClickableViewAccessibility") private void initBottomSheets() { - bottomSheetDetailsBehavior = BottomSheetBehavior.from(binding.bottomSheetDetailsBinding.getRoot()); + bottomSheetDetailsBehavior = BottomSheetBehavior.from( + binding.bottomSheetDetailsBinding.getRoot()); bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); binding.bottomSheetDetailsBinding.getRoot().setVisibility(View.VISIBLE); } @@ -404,7 +459,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment if (currentLatLng == null) { return; } - if (currentLatLng.equals(getLastMapFocus())) { // Means we are checking around current location + if (currentLatLng.equals( + getLastMapFocus())) { // Means we are checking around current location nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(currentLatLng, getLastMapFocus(), true); } else { @@ -416,11 +472,12 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment .observeOn(AndroidSchedulers.mainThread()) .subscribe(explorePlacesInfo -> { mediaList = explorePlacesInfo.mediaList; - if(mediaList == null) { + if (mediaList == null) { showResponseMessage(getString(R.string.no_pictures_in_this_area)); } updateMapMarkers(explorePlacesInfo); - lastMapFocus = new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude()); + lastMapFocus = new GeoPoint(currentLatLng.getLatitude(), + currentLatLng.getLongitude()); }, throwable -> { Timber.d(throwable); @@ -474,9 +531,9 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER); locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); setProgressBarVisibility(true); - } - else { - locationPermissionsHelper.showLocationOffDialog(getActivity(), R.string.ask_to_turn_location_on_text); + } else { + locationPermissionsHelper.showLocationOffDialog(getActivity(), + R.string.ask_to_turn_location_on_text); } presenter.onMapReady(exploreMapController); registerUnregisterLocationListener(false); @@ -508,7 +565,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment recenterToUserLocation = true; return; } - recenterMarkerToPosition(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); + recenterMarkerToPosition( + new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); binding.mapView.getController() .animateTo(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); if (lastMapFocus != null) { @@ -549,7 +607,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment view -> Utils.handleGeoCoordinates(getActivity(), place.getLocation(), binding.mapView.getZoomLevelDouble())); - binding.bottomSheetDetailsBinding.commonsButton.setVisibility(place.hasCommonsLink() ? View.VISIBLE : View.GONE); + binding.bottomSheetDetailsBinding.commonsButton.setVisibility( + place.hasCommonsLink() ? View.VISIBLE : View.GONE); binding.bottomSheetDetailsBinding.commonsButton.setOnClickListener( view -> Utils.handleWebUrl(getContext(), place.siteLinks.getCommonsLink())); @@ -563,7 +622,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment } index++; } - binding.bottomSheetDetailsBinding.title.setText(place.name.substring(5, place.name.lastIndexOf("."))); + binding.bottomSheetDetailsBinding.title.setText( + place.name.substring(5, place.name.lastIndexOf("."))); binding.bottomSheetDetailsBinding.category.setText(place.distance); // Remove label since it is double information String descriptionText = place.getLongDescription() @@ -641,40 +701,43 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment * @param nearbyBaseMarker The NearbyBaseMarker object representing the marker to be added. */ private void addMarkerToMap(BaseMarker nearbyBaseMarker) { - ArrayList items = new ArrayList<>(); - Bitmap icon = nearbyBaseMarker.getIcon(); - Drawable d = new BitmapDrawable(getResources(), icon); - GeoPoint point = new GeoPoint( - nearbyBaseMarker.getPlace().location.getLatitude(), - nearbyBaseMarker.getPlace().location.getLongitude()); - OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null, - point); - item.setMarker(d); - items.add(item); - ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items, - new OnItemGestureListener() { - @Override - public boolean onItemSingleTapUp(int index, OverlayItem item) { - final Place place = nearbyBaseMarker.getPlace(); - if (clickedMarker != null) { - removeMarker(clickedMarker); - addMarkerToMap(clickedMarker); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + if (isAttachedToActivity()) { + ArrayList items = new ArrayList<>(); + Bitmap icon = nearbyBaseMarker.getIcon(); + Drawable d = new BitmapDrawable(getResources(), icon); + GeoPoint point = new GeoPoint( + nearbyBaseMarker.getPlace().location.getLatitude(), + nearbyBaseMarker.getPlace().location.getLongitude()); + OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null, + point); + item.setMarker(d); + items.add(item); + ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items, + new OnItemGestureListener() { + @Override + public boolean onItemSingleTapUp(int index, OverlayItem item) { + final Place place = nearbyBaseMarker.getPlace(); + if (clickedMarker != null) { + removeMarker(clickedMarker); + addMarkerToMap(clickedMarker); + bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + bottomSheetDetailsBehavior.setState( + BottomSheetBehavior.STATE_COLLAPSED); + } + clickedMarker = nearbyBaseMarker; + passInfoToSheet(place); + return true; } - clickedMarker = nearbyBaseMarker; - passInfoToSheet(place); - return true; - } - @Override - public boolean onItemLongPress(int index, OverlayItem item) { - return false; - } - }, getContext()); + @Override + public boolean onItemLongPress(int index, OverlayItem item) { + return false; + } + }, getContext()); - overlay.setFocusItemsOnTap(true); - binding.mapView.getOverlays().add(overlay); // Add the overlay to the map + overlay.setFocusItemsOnTap(true); + binding.mapView.getOverlays().add(overlay); // Add the overlay to the map + } } /** @@ -708,68 +771,72 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment */ @Override public void clearAllMarkers() { - binding.mapView.getOverlayManager().clear(); - GeoPoint geoPoint = mapCenter; - if (geoPoint != null) { - List overlays = binding.mapView.getOverlays(); - ScaleDiskOverlay diskOverlay = - new ScaleDiskOverlay(this.getContext(), - 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); - binding.mapView.getOverlays().add(diskOverlay); - org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( - binding.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.getContext(), R.drawable.current_location_marker)); - startMarker.setTitle("Your Location"); - startMarker.setTextLabelFontSize(24); - binding.mapView.getOverlays().add(startMarker); - } - ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView); - scaleBarOverlay.setScaleBarOffset(15, 25); - Paint barPaint = new Paint(); - barPaint.setARGB(200, 255, 250, 250); - scaleBarOverlay.setBackgroundPaint(barPaint); - scaleBarOverlay.enableScaleBar(); - binding.mapView.getOverlays().add(scaleBarOverlay); - binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { - @Override - public boolean singleTapConfirmedHelper(GeoPoint p) { - if (clickedMarker != null) { - removeMarker(clickedMarker); - addMarkerToMap(clickedMarker); - binding.mapView.invalidate(); - } else { - Timber.e("CLICKED MARKER IS NULL"); - } - if (bottomSheetDetailsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - // Back should first hide the bottom sheet if it is expanded - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else if (isDetailsBottomSheetVisible()) { - hideBottomDetailsSheet(); - } - return true; + if (isAttachedToActivity()) { + binding.mapView.getOverlayManager().clear(); + GeoPoint geoPoint = mapCenter; + if (geoPoint != null) { + List overlays = binding.mapView.getOverlays(); + ScaleDiskOverlay diskOverlay = + new ScaleDiskOverlay(this.getContext(), + 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); + binding.mapView.getOverlays().add(diskOverlay); + org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( + binding.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.getContext(), + R.drawable.current_location_marker)); + startMarker.setTitle("Your Location"); + startMarker.setTextLabelFontSize(24); + binding.mapView.getOverlays().add(startMarker); } + ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView); + scaleBarOverlay.setScaleBarOffset(15, 25); + Paint barPaint = new Paint(); + barPaint.setARGB(200, 255, 250, 250); + scaleBarOverlay.setBackgroundPaint(barPaint); + scaleBarOverlay.enableScaleBar(); + binding.mapView.getOverlays().add(scaleBarOverlay); + binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { + @Override + public boolean singleTapConfirmedHelper(GeoPoint p) { + if (clickedMarker != null) { + removeMarker(clickedMarker); + addMarkerToMap(clickedMarker); + binding.mapView.invalidate(); + } else { + Timber.e("CLICKED MARKER IS NULL"); + } + if (bottomSheetDetailsBehavior.getState() + == BottomSheetBehavior.STATE_EXPANDED) { + // Back should first hide the bottom sheet if it is expanded + bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } else if (isDetailsBottomSheetVisible()) { + hideBottomDetailsSheet(); + } + return true; + } - @Override - public boolean longPressHelper(GeoPoint p) { - return false; - } - })); - binding.mapView.setMultiTouchControls(true); + @Override + public boolean longPressHelper(GeoPoint p) { + return false; + } + })); + binding.mapView.setMultiTouchControls(true); + } } /** @@ -826,6 +893,18 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment binding.mapView.getController().animateTo(geoPoint); } + /** + * Moves the camera of the map view to the specified GeoPoint at specified zoom level and speed + * using an animation. + * + * @param geoPoint The GeoPoint representing the new camera position for the map. + * @param zoom Zoom level of the map camera + * @param speed Speed of animation + */ + private void moveCameraToPosition(GeoPoint geoPoint, double zoom, long speed) { + binding.mapView.getController().animateTo(geoPoint, zoom, speed); + } + @Override public fr.free.nrw.commons.location.LatLng getLastMapFocus() { return lastMapFocus == null ? getMapCenter() : new fr.free.nrw.commons.location.LatLng( @@ -851,14 +930,17 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment -0.07483536015053005, 1f); } } - moveCameraToPosition(new GeoPoint(latLnge.getLatitude(),latLnge.getLongitude())); + if (!isCameFromNearbyMap()) { + moveCameraToPosition(new GeoPoint(latLnge.getLatitude(), latLnge.getLongitude())); + } return latLnge; } @Override public fr.free.nrw.commons.location.LatLng getMapFocus() { fr.free.nrw.commons.location.LatLng mapFocusedLatLng = new fr.free.nrw.commons.location.LatLng( - binding.mapView.getMapCenter().getLatitude(), binding.mapView.getMapCenter().getLongitude(), 100); + binding.mapView.getMapCenter().getLatitude(), + binding.mapView.getMapCenter().getLongitude(), 100); return mapFocusedLatLng; } @@ -911,9 +993,19 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment }; } - @Override - public void onLocationPermissionDenied(String toastMessage) {} + /** + * helper function to confirm that this fragment has been attached. + **/ + public boolean isAttachedToActivity() { + boolean attached = isVisible() && getActivity() != null; + return attached; + } @Override - public void onLocationPermissionGranted() {} + public void onLocationPermissionDenied(String toastMessage) { + } + + @Override + public void onLocationPermissionGranted() { + } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index ee15d13ac..95f19f699 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -233,6 +233,11 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment private Place nearestPlace; private volatile boolean stopQuery; + // Explore map data (for if we came from Explore) + private double prevZoom; + private double prevLatitude; + private double prevLongitude; + private final Handler searchHandler = new Handler(); private Runnable searchRunnable; @@ -247,27 +252,28 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment private final ActivityResultLauncher galleryPickLauncherForResult = registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); + }); }); - }); private final ActivityResultLauncher customSelectorLauncherForResult = registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCustomSelector(result, requireActivity(), + callbacks); + }); }); - }); private final ActivityResultLauncher cameraPickLauncherForResult = registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + result -> { + controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { + controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); + }); }); - }); private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( new RequestMultiplePermissions(), @@ -337,12 +343,15 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { + loadExploreMapData(); + binding = FragmentNearbyParentBinding.inflate(inflater, container, false); view = binding.getRoot(); initNetworkBroadCastReceiver(); scope = LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner()); - presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, nearbyController); + presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, + nearbyController); progressDialog = new ProgressDialog(getActivity()); progressDialog.setCancelable(false); progressDialog.setMessage("Saving in progress..."); @@ -359,6 +368,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment inflater.inflate(R.menu.nearby_fragment_menu, menu); MenuItem refreshButton = menu.findItem(R.id.item_refresh); MenuItem listMenu = menu.findItem(R.id.list_sheet); + MenuItem showInExploreButton = menu.findItem(R.id.list_item_show_in_explore); MenuItem saveAsGPXButton = menu.findItem(R.id.list_item_gpx); MenuItem saveAsKMLButton = menu.findItem(R.id.list_item_kml); refreshButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { @@ -379,6 +389,17 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment return false; } }); + showInExploreButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(@NonNull MenuItem item) { + ((MainActivity) getContext()).loadExploreMapFromNearby( + binding.map.getZoomLevelDouble(), + binding.map.getMapCenter().getLatitude(), + binding.map.getMapCenter().getLongitude() + ); + return false; + } + }); saveAsGPXButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { @Override @@ -467,6 +488,14 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment binding.map.getOverlays().add(scaleBarOverlay); binding.map.getZoomController().setVisibility(Visibility.NEVER); binding.map.getController().setZoom(ZOOM_LEVEL); + // if we came from Explore map using 'Show in Nearby', load Explore map camera position + if (isCameFromExploreMap()) { + moveCameraToPosition( + new GeoPoint(prevLatitude, prevLongitude), + prevZoom, + 1L + ); + } binding.map.getOverlays().add(mapEventsOverlay); binding.map.addMapListener(new MapListener() { @@ -489,11 +518,14 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } initNearbyFilter(); addCheckBoxCallback(); - moveCameraToPosition(lastMapFocus); + if (!isCameFromExploreMap()) { + moveCameraToPosition(lastMapFocus); + } initRvNearbyList(); onResume(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); + binding.tvAttribution.setText( + Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); } else { //noinspection deprecation binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); @@ -545,6 +577,28 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } } + /** + * Fetch Explore map camera data from fragment arguments if any. + */ + public void loadExploreMapData() { + // get fragment arguments + if (getArguments() != null) { + prevZoom = getArguments().getDouble("prev_zoom"); + prevLatitude = getArguments().getDouble("prev_latitude"); + prevLongitude = getArguments().getDouble("prev_longitude"); + } + } + + /** + * Checks if fragment arguments contain data from Explore map. if present, then the user + * navigated from Explore using 'Show in Nearby'. + * + * @return true if user navigated from Explore map + **/ + public boolean isCameFromExploreMap() { + return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0; + } + /** * Initialise background based on theme, this should be doe ideally via styles, that would need * another refactor @@ -625,7 +679,9 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment mapCenter = targetP; binding.map.getController().setCenter(targetP); recenterMarkerToPosition(targetP); - moveCameraToPosition(targetP); + if (!isCameFromExploreMap()) { + moveCameraToPosition(targetP); + } } else if (locationManager.isGPSProviderEnabled() || locationManager.isNetworkProviderEnabled()) { locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER); @@ -669,7 +725,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } else { lastKnownLocation = MapUtils.getDefaultLatLng(); } - if (binding.map != null) { + if (binding.map != null && !isCameFromExploreMap()) { moveCameraToPosition( new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); } @@ -739,8 +795,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } /** - * Determines the number of spans (columns) in the RecyclerView based on device orientation - * and adapter item count. + * Determines the number of spans (columns) in the RecyclerView based on device orientation and + * adapter item count. * * @return The number of spans to be used in the RecyclerView. */ @@ -1175,7 +1231,6 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment /** * Clears the Nearby local cache and then calls for pin details to be fetched afresh. - * */ private void emptyCache() { // reload the map once the cache is cleared @@ -1338,7 +1393,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } /** - * Fetches and updates the data for a specific place, then updates the corresponding marker on the map. + * Fetches and updates the data for a specific place, then updates the corresponding marker on + * the map. * * @param entity The entity ID of the place. * @param place The Place object containing the initial place data. @@ -1469,9 +1525,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } /** - * Stops any ongoing queries and clears all disposables. - * This method sets the stopQuery flag to true and clears the compositeDisposable - * to prevent any further processing. + * Stops any ongoing queries and clears all disposables. This method sets the stopQuery flag to + * true and clears the compositeDisposable to prevent any further processing. */ @Override public void stopQuery() { @@ -1624,7 +1679,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment new Builder(getContext()) .setMessage(R.string.login_alert_message) .setCancelable(false) - .setNegativeButton(R.string.cancel, (dialog, which) -> {}) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + }) .setPositiveButton(R.string.login, (dialog, which) -> { // logout of the app BaseLogoutListener logoutListener = new BaseLogoutListener(getActivity()); @@ -1743,7 +1799,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final boolean filterForPlaceState, final boolean filterForAllNoneType) { final boolean displayExists = false; - final boolean displayNeedsPhoto= false; + final boolean displayNeedsPhoto = false; final boolean displayWlm = false; if (selectedLabels == null || selectedLabels.size() == 0) { replaceMarkerOverlays(NearbyController.markerLabelList); @@ -1903,8 +1959,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment /** * Adds multiple markers representing places to the map and handles item gestures. * - * @param markerPlaceGroups The list of marker place groups containing the places and - * their bookmarked status + * @param markerPlaceGroups The list of marker place groups containing the places and their + * bookmarked status */ @Override public void replaceMarkerOverlays(final List markerPlaceGroups) { @@ -1913,7 +1969,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment for (int i = markerPlaceGroups.size() - 1; i >= 0; i--) { newMarkers.add( convertToMarker(markerPlaceGroups.get(i).getPlace(), - markerPlaceGroups.get(i).getIsBookmarked()) + markerPlaceGroups.get(i).getIsBookmarked()) ); } clearAllMarkers(); @@ -2103,7 +2159,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment if (binding.fabCamera.isShown()) { Timber.d("Camera button tapped. Place: %s", selectedPlace.toString()); storeSharedPrefs(selectedPlace); - controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); + controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, + cameraPickLauncherForResult); } }); @@ -2121,7 +2178,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment if (binding.fabCustomGallery.isShown()) { Timber.d("Gallery button tapped. Place: %s", selectedPlace.toString()); storeSharedPrefs(selectedPlace); - controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult); + controller.initiateCustomGalleryPickWithPermission(getActivity(), + customSelectorLauncherForResult); } }); } @@ -2296,6 +2354,18 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment binding.map.getController().animateTo(geoPoint); } + /** + * Moves the camera of the map view to the specified GeoPoint at specified zoom level and speed + * using an animation. + * + * @param geoPoint The GeoPoint representing the new camera position for the map. + * @param zoom Zoom level of the map camera + * @param speed Speed of animation + */ + private void moveCameraToPosition(GeoPoint geoPoint, double zoom, long speed) { + binding.map.getController().animateTo(geoPoint, zoom, speed); + } + @Override public void onBottomSheetItemClick(@Nullable View view, int position) { BottomSheetItem item = dataList.get(position); diff --git a/app/src/main/res/menu/explore_fragment_menu.xml b/app/src/main/res/menu/explore_fragment_menu.xml new file mode 100644 index 000000000..4dce1c57c --- /dev/null +++ b/app/src/main/res/menu/explore_fragment_menu.xml @@ -0,0 +1,19 @@ + +

+ + + + \ No newline at end of file diff --git a/app/src/main/res/menu/nearby_fragment_menu.xml b/app/src/main/res/menu/nearby_fragment_menu.xml index fe049cde4..e7c23ed89 100644 --- a/app/src/main/res/menu/nearby_fragment_menu.xml +++ b/app/src/main/res/menu/nearby_fragment_menu.xml @@ -12,6 +12,12 @@ android:icon="@drawable/ic_list_white_24dp" /> + + Caption copied to clipboard Congratulations, all pictures in this album have been either uploaded or marked as not for upload. + Show in Explore + Show in Nearby diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreFragmentUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreFragmentUnitTest.kt index 41c999791..e2ef3bb92 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreFragmentUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreFragmentUnitTest.kt @@ -11,16 +11,19 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import androidx.test.core.app.ApplicationProvider import com.google.android.material.tabs.TabLayout +import com.nhaarman.mockitokotlin2.eq import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.R import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.contributions.MainActivity import fr.free.nrw.commons.createTestClient import org.junit.Assert +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` @@ -34,6 +37,7 @@ import org.robolectric.annotation.LooperMode import org.robolectric.fakes.RoboMenu import org.robolectric.fakes.RoboMenuItem + @RunWith(RobolectricTestRunner::class) @Config(sdk = [21], application = TestCommonsApplication::class) @LooperMode(LooperMode.Mode.PAUSED) @@ -151,6 +155,14 @@ class ExploreFragmentUnitTest { Shadows.shadowOf(getMainLooper()).idle() val menu: Menu = RoboMenu(context) fragment.onCreateOptionsMenu(menu, inflater) - verify(inflater).inflate(R.menu.menu_search, menu) + + val captor = ArgumentCaptor.forClass( + Int::class.java + ) + verify(inflater).inflate(captor.capture(), eq(menu)) + + val capturedLayout = captor.value + assertTrue(capturedLayout == R.menu.menu_search || capturedLayout == R.menu.explore_fragment_menu) + } } From e6538574374267b833f5a436e21389d84ea38562 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Fri, 31 Jan 2025 09:49:38 +0100 Subject: [PATCH 08/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-ar/strings.xml | 3 +++ app/src/main/res/values-da/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 3 +++ 4 files changed, 9 insertions(+) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f3b7d8467..577b031cb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -5,6 +5,7 @@ * Asma * Azouz.anis * ButterflyOfFire +* Cigaryno * Claw eg * Dr-Taher * Dr. Mohammed @@ -884,4 +885,6 @@ الشرح تم نسخ التسمية التوضيحية إلى الحافظة مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل. + عرض في استكشاف + عرض في المناطق القريبة diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e7f66a69e..232f56c49 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -821,4 +821,6 @@ Billedtekst Billedtekst kopieret til udklipsholder Tillykke, alle billeder i dette album er enten blevet uploadet eller markeret som ikke til upload. + Vis i Udforsk + Vis i I nærheden diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cd974dc30..1e482148e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -709,6 +709,7 @@ 이 파일을 사용하는 문서 계정 계정 버리기 + 버리기는 <b>최후의 수단</b>이며 <b>영원히 편집을 중단하고 싶을 때</b>와 가능한 한 많은 과거 연관성을 숨기고 싶을 때만 사용해야 합니다.<br/><br/>위키미디어 공용에서 계정을 삭제하려면 계정 이름을 변경하여 다른 사람이 기여한 내용을 알아볼 수 없도록 하는 계정 버리기라는 프로세스를 거쳐야 합니다. <b>버리기는 완전한 익명성을 보장하지 않으며 프로젝트에 수행한 기여를 제거하지 않습니다</b>. 캡션 캡션이 클립보드에 복사되었습니다 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0131fef5d..1c01761e0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -333,6 +333,7 @@ Копирование викикода в буфер обмена Викикод скопирован в буфер обмена Функция «Поблизости» может работать некорректно, определение местоположения недоступно. + Интернет недоступен. Показаны только кэшированные места. Доступ к местоположению запрещён. Чтобы использовать эту функцию, укажите своё местоположение вручную. Необходимо разрешение для отображения списка мест поблизости Необходимо разрешение для отображения списка мест поблизости @@ -869,7 +870,9 @@ Викисклад Другие вики Использование файла + SingleWebViewActivity Учётная запись Подпись Подпись скопирована в буфер обмена + Поздравляем, все фотографии в этом альбоме либо загружены, либо помечены как не предназначенные для загрузки. From 7566ddf529c4b7cbcbb771c05b6fe3655f92e0bf Mon Sep 17 00:00:00 2001 From: Parneet Singh <111801812+parneet-guraya@users.noreply.github.com> Date: Sat, 1 Feb 2025 05:43:17 +0530 Subject: [PATCH 09/22] enhance spammy category filter (#6167) Signed-off-by: parneet-guraya --- app/.attach_pid781771 | 0 .../nrw/commons/category/CategoriesModel.kt | 46 +++++++++---------- .../commons/category/CategoriesModelTest.kt | 39 ++++++++++++++++ 3 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 app/.attach_pid781771 diff --git a/app/.attach_pid781771 b/app/.attach_pid781771 new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt index fd90be95f..47147944c 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -36,37 +36,35 @@ class CategoriesModel * @return */ fun isSpammyCategory(item: String): Boolean { - // Check for current and previous year to exclude these categories from removal - val now = Calendar.getInstance() - val curYear = now[Calendar.YEAR] - val curYearInString = curYear.toString() - val prevYear = curYear - 1 - val prevYearInString = prevYear.toString() - Timber.d("Previous year: %s", prevYearInString) - - val mentionsDecade = item.matches(".*0s.*".toRegex()) - val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) - val spammyCategory = - item.matches("(.*)needing(.*)".toRegex()) || - item.matches("(.*)taken on(.*)".toRegex()) // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) + val spammyCategory = item.matches("(.*)needing(.*)".toRegex()) + || item.matches("(.*)taken on(.*)".toRegex()) + + // checks for + // dd/mm/yyyy or yy + // yyyy or yy/mm/dd + // yyyy or yy/mm + // mm/yyyy or yy + // for `yy` it is assumed that 20XX is implicit. + // with separators [., /, -] + val isIrrelevantCategory = + item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex()) + + if (spammyCategory) { return true } - if (mentionsDecade) { - // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 - // Example: "2020s" is OK, but "1920s" is not (and should be skipped) - return !recentDecade - } else { - // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year - // anywhere within the string (.* is wildcard) (Issue #47) - // And that item does not equal the current year or previous year - return item.matches(".*(19|20)\\d{2}.*".toRegex()) && - !item.contains(curYearInString) && - !item.contains(prevYearInString) + if(isIrrelevantCategory){ + return true } + + val hasYear = item.matches("(.*\\d{4}.*)".toRegex()) + val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex()) + + // finally if there's 4 digits year exists in XXXX it should only be in 20XX range. + return hasYear && !validYearsRange } /** diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt index 8c336470a..21fdba2f5 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoriesModelTest.kt @@ -11,6 +11,7 @@ import fr.free.nrw.commons.upload.GpsCategoryModel import io.reactivex.Single import io.reactivex.subjects.BehaviorSubject import media +import org.junit.Assert import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers @@ -331,4 +332,42 @@ class CategoriesModelTest { media(), ) } + + @Test + fun `test valid input with XXXX in it between the expected range 20XX`() { + val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014)") + Assert.assertFalse(input) + } + + @Test + fun `test valid input with XXXXs in it between the expected range 20XXs`() { + val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014s)") + Assert.assertFalse(input) + } + + @Test + fun `test invalid category when have needing in the input`() { + val input = categoriesModel.isSpammyCategory("Media needing categories as of 30 March 2017") + Assert.assertTrue(input) + } + + @Test + fun `test invalid category when have taken on in the input`() { + val input = categoriesModel.isSpammyCategory("Photographs taken on 2015-12-08") + Assert.assertTrue(input) + } + + @Test + fun `test invalid category when have yy mm or yy mm dd in the input`() { + // filtering based on [., /, -] separators between the dates. + val input = categoriesModel.isSpammyCategory("Image class 09.14") + Assert.assertTrue(input) + } + + @Test + fun `test invalid category when have years not in 20XX range`() { + val input = categoriesModel.isSpammyCategory("Japan in the 1400s") + Assert.assertTrue(input) + } + } From 0293b865b42da9507199cc25d456353aaf39df40 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Mon, 3 Feb 2025 13:01:34 +0100 Subject: [PATCH 10/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-iw/strings.xml | 12 +++++++----- app/src/main/res/values-lt/strings.xml | 21 ++++++++++++++++++++- app/src/main/res/values-mk/strings.xml | 2 ++ app/src/main/res/values-pms/strings.xml | 2 ++ app/src/main/res/values-ps/strings.xml | 2 +- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07021189f..5aeda5299 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -855,4 +855,6 @@ Utilisations du fichier Légende Légende copiée dans le presse-papier + Afficher dans Explorer + Afficher à proximité diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 61381c407..7f2d9a8f1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -85,7 +85,7 @@ מתקבל תוכן שיתופי. עיבוד התמונות עשוי לארוך זמן מה כתלות בגודל התמונות והמכשיר שלך מתקבל תוכן שיתופי. עיבוד התמונות עשוי לארוך זמן מה כתלות בגודל התמונות והמכשיר שלך - לחקור + סיור מראה כללי משוב @@ -311,7 +311,7 @@ האינטרנט אינו זמין. מוצגים רק מקומות שמורים. הגישה למיקום נדחתה. נא להגדיר את המקום שלך ידנית כדי להשתמש ביכולת הזאת. נדרשת הרשאה כדי להציג רשימה של מקומות בסביבה - נדרשת הרשאה להצגת רשימת התמונות בסביבתך + נדרשת הרשאה כדי להציג רשימה של תמונות בסביבה כיוונים ויקינתונים ויקיפדיה @@ -605,12 +605,12 @@ קיים זקוק לתצלום סוג המקום: - גשר, מוזאון, מלון, וכו\'. + גשר, מוזאון, מלון וכו\'. משהו השתבש בכניסה למערכת, עליך לאפס את הסיסמה שלך! מדיה מחלקות יורשות מחלקות מורישות - נמצא בקרבת מקום + נמצא מקום בסביבה האם אלו תמונות של %1$s? האם זאת תמונה של %1$s? סימניות @@ -628,7 +628,7 @@ להפעיל מיקום? נא להפעיל את שירותי המיקום כדי שהיישום יוכל להציג את מיקומך הנוכחי פעולת \"בסביבה\" זקוקה לשירותי מיקומי פועלים כדי לעבוד כמו שצריך - חקירת המפה דורשת הרשאות מיקום כדי להציג תמונות בסביבתך + מפת \"סיור\" דורשת הרשאות מיקום כדי להציג תמונות בסביבתך יש להעניק הרשאת מיקום כדי להגדיר את המיקום אוטומטית. האם צילמת את שתי התמונות באותו המקום? האם ברצונך להשתמש בקו הרוחב וקו האורך של התמונה משמאל? לטעון עוד @@ -852,4 +852,6 @@ כותרת הכותרת הועתקה ללוח ברכותינו, כל התמונות באלבום הזה הועלו או שסומנו לא להעלאה. + בתצוגת סיור + בתצוגת בסביבה diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 89a5af3ad..04c1f931f 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -96,6 +96,8 @@ Nufotografuoti Netoliese Mano įkėlimai + Kopijuoti nuorodą + Nuoroda nukopijuota į mainų sritį. Dalintis Žiūrėti failo puslapį Antraštė (būtina) @@ -345,11 +347,13 @@ Ištrinti Pasiekimai Profilis + Ženkliukai Statistika Gauta padėka Rinktiniai paveikslėliai Vaizdai per „Netoliese esančios vietos“ - Lygis + Lygis %d + %s (%s lygis) Vaizdai įkelti Paveikslėliai negrąžinti Naudoti vaizdai @@ -381,6 +385,7 @@ Jūsų įrenginyje nepavyko rasti suderinamos žemėlapio programos. Norėdami naudotis šia funkcija, įdiekite žemėlapio programą. Nuotraukos Vietos + Kategorijos Pridėti prie / pašalinti iš žymių Žymės Jūs nepridėjote jokių žymių @@ -751,4 +756,18 @@ Laukiama Nepavyko Nepavyko įkelti vietos duomenų + Trinti aplanką + Patvirtinti ištrynimą + Ar tikrai norite ištrinti aplanką %1$s, kuriame yra %2$d elementų? + Ištrinti + Atšaukti + Aplankas %1$s sėkmingai ištrintas + Nepavyko ištrinti aplanko %1$s + Klaida išsiunčiant į šiukšliadėžę aplanko turinį: %1$s + Įkeliant įvyko klaida + Naudojimo būdų nerasta + Vikiteka + Kiti viki + Failo naudojimas + Paskyra diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index 15d6789fd..94e8f638b 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -818,4 +818,6 @@ Толкување Толкувањето е ставено во меѓускладот Честитаме. Сите слики од овој албум се подигнати или обележани за неподигање. + Прикажи во „Истражи“ + Прикажи во „Во близина“ diff --git a/app/src/main/res/values-pms/strings.xml b/app/src/main/res/values-pms/strings.xml index 0e3063ac7..76a0be425 100644 --- a/app/src/main/res/values-pms/strings.xml +++ b/app/src/main/res/values-pms/strings.xml @@ -814,4 +814,6 @@ Legenda Legenda copià an sla taulëtta Congratulassion, tute le fòto ëd s\'àlbom a son ëstàita carià opura marcà coma da nen carié. + Smon-e andrinta a Explore + Smon-e andrinta a Nearby diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml index ca52b10d8..2dc2366a8 100644 --- a/app/src/main/res/values-ps/strings.xml +++ b/app/src/main/res/values-ps/strings.xml @@ -15,7 +15,7 @@ دا انځور به د %1$s په منښتليک سمبال وي. ويکي خونديځ امستنې - کارن-نوم + کارن‌نوم پټنوم ننوتل نومليکنه From 30a7f702a11094858df393e9afeb67bbc966ce69 Mon Sep 17 00:00:00 2001 From: Matija Nalis Date: Tue, 4 Feb 2025 11:34:14 +0100 Subject: [PATCH 11/22] GitHub workflow to build betaDebug (#6174) --- .github/workflows/build-beta.yml | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/build-beta.yml diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 000000000..933d08e3e --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -0,0 +1,41 @@ +name: Build beta only + +on: [workflow_dispatch] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Access test login credentials + run: | + echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties + echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Set env + run: echo "COMMIT_SHA=$(git log -n 1 --format='%h')" >> $GITHUB_ENV + + - name: Generate betaDebug APK + run: ./gradlew assembleBetaDebug --stacktrace + + - name: Rename betaDebug APK + run: mv app/build/outputs/apk/beta/debug/app-*.apk app/build/outputs/apk/beta/debug/apps-android-commons-betaDebug-$COMMIT_SHA.apk + + - name: Upload betaDebug APK + uses: actions/upload-artifact@v4 + with: + name: apps-android-commons-betaDebugAPK-${{ env.COMMIT_SHA }} + path: app/build/outputs/apk/beta/debug/*.apk + retention-days: 30 From 43dca1dd147885b4bc7fca39bd2adf178fcafed4 Mon Sep 17 00:00:00 2001 From: Parneet Singh <111801812+parneet-guraya@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:50:22 +0530 Subject: [PATCH 12/22] [Bug fix] Check if duplicate exist using both original and modified file's checksum (#6169) * check original file's SHA too along with modified one Signed-off-by: parneet-guraya * fix tests Signed-off-by: parneet-guraya --------- Signed-off-by: parneet-guraya --- .../contributions/ContributionsPresenter.java | 5 ++- .../commons/repository/UploadRepository.kt | 5 ++- .../commons/upload/ImageProcessingService.kt | 37 ++++++++++++++----- .../commons/upload/PendingUploadsPresenter.kt | 10 ++++- .../fr/free/nrw/commons/upload/UploadModel.kt | 7 +++- .../upload/ImageProcessingServiceTest.kt | 14 ++++++- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java index 495a4bc64..4d05711f3 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java @@ -62,7 +62,10 @@ public class ContributionsPresenter implements UserActionListener { */ public void checkDuplicateImageAndRestartContribution(Contribution contribution) { compositeDisposable.add(uploadRepository - .checkDuplicateImage(contribution.getLocalUriPath().getPath()) + .checkDuplicateImage( + contribution.getContentUri(), + contribution.getLocalUri() + ) .subscribeOn(ioThreadScheduler) .subscribe(imageCheckResult -> { if (imageCheckResult == IMAGE_OK) { diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt index 9acdde32c..a27993f9e 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt @@ -1,5 +1,6 @@ package fr.free.nrw.commons.repository +import android.net.Uri import fr.free.nrw.commons.Media import fr.free.nrw.commons.category.CategoriesModel import fr.free.nrw.commons.category.CategoryItem @@ -203,8 +204,8 @@ class UploadRepository @Inject constructor( * @param filePath file to be checked * @return IMAGE_DUPLICATE or IMAGE_OK */ - fun checkDuplicateImage(filePath: String): Single { - return uploadModel.checkDuplicateImage(filePath) + fun checkDuplicateImage(originalFilePath: Uri, modifiedFilePath: Uri): Single { + return uploadModel.checkDuplicateImage(originalFilePath, modifiedFilePath) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt index fca10be1e..3acd13c65 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/ImageProcessingService.kt @@ -1,5 +1,7 @@ package fr.free.nrw.commons.upload +import android.content.Context +import android.net.Uri import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.nearby.Place @@ -13,7 +15,7 @@ import io.reactivex.Single import io.reactivex.schedulers.Schedulers import org.apache.commons.lang3.StringUtils import timber.log.Timber -import java.io.FileInputStream +import java.io.InputStream import javax.inject.Inject import javax.inject.Singleton @@ -26,7 +28,8 @@ class ImageProcessingService @Inject constructor( private val imageUtilsWrapper: ImageUtilsWrapper, private val readFBMD: ReadFBMD, private val exifReader: EXIFReader, - private val mediaClient: MediaClient + private val mediaClient: MediaClient, + private val appContext: Context ) { /** * Check image quality before upload - checks duplicate image - checks dark image - checks @@ -47,7 +50,10 @@ class ImageProcessingService @Inject constructor( val filePath = uploadItem.mediaUri?.path return Single.zip( - checkDuplicateImage(filePath), + checkIfFileAlreadyExists( + originalFilePath = uploadItem.contentUri!!, + modifiedFilePath = uploadItem.mediaUri!! + ), checkImageGeoLocation(uploadItem.place, filePath, inAppPictureLocation), checkDarkImage(filePath!!), checkFBMD(filePath), @@ -114,18 +120,31 @@ class ImageProcessingService @Inject constructor( .subscribeOn(Schedulers.io()) } + /** + * Checks if file already exists using original & modified file's SHA + * + * @param originalFilePath original file to be checked + * @param modifiedFilePath modified (after exif modifications) file to be checked + * @return IMAGE_DUPLICATE or IMAGE_OK + */ + fun checkIfFileAlreadyExists(originalFilePath: Uri, modifiedFilePath: Uri): Single { + return Single.zip( + checkDuplicateImage(inputStream = appContext.contentResolver.openInputStream(originalFilePath)!!), + checkDuplicateImage(inputStream = fileUtilsWrapper.getFileInputStream(modifiedFilePath.path)) + ) { resultForOriginal, resultForDuplicate -> + return@zip if (resultForOriginal == IMAGE_DUPLICATE || resultForDuplicate == IMAGE_DUPLICATE) + IMAGE_DUPLICATE else IMAGE_OK + } + } + /** * Checks for duplicate image * * @param filePath file to be checked * @return IMAGE_DUPLICATE or IMAGE_OK */ - fun checkDuplicateImage(filePath: String?): Single { - Timber.d("Checking for duplicate image %s", filePath) - return Single.fromCallable { fileUtilsWrapper.getFileInputStream(filePath) } - .map { stream: FileInputStream? -> - fileUtilsWrapper.getSHA1(stream) - } + private fun checkDuplicateImage(inputStream: InputStream): Single { + return Single.fromCallable { fileUtilsWrapper.getSHA1(inputStream) } .flatMap { fileSha: String? -> mediaClient.checkFileExistsUsingSha(fileSha) } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt index 324f988d4..0b2c47ddc 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt @@ -149,7 +149,10 @@ class PendingUploadsPresenter @Inject internal constructor( } compositeDisposable.add( uploadRepository - .checkDuplicateImage(contribution.localUriPath!!.path) + .checkDuplicateImage( + originalFilePath = contribution.contentUri!!, + modifiedFilePath = contribution.localUri!! + ) .subscribeOn(ioThreadScheduler) .subscribe({ imageCheckResult: Int -> if (imageCheckResult == IMAGE_OK) { @@ -218,7 +221,10 @@ class PendingUploadsPresenter @Inject internal constructor( } compositeDisposable.add( uploadRepository - .checkDuplicateImage(contribution.localUriPath!!.path) + .checkDuplicateImage( + originalFilePath = contribution.contentUri!!, + modifiedFilePath = contribution.localUri!! + ) .subscribeOn(ioThreadScheduler) .subscribe { imageCheckResult: Int -> if (imageCheckResult == IMAGE_OK) { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt index c7ffe06f7..2cda7a890 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.kt @@ -103,8 +103,11 @@ class UploadModel @Inject internal constructor( * @param filePath file to be checked * @return IMAGE_DUPLICATE or IMAGE_OK */ - fun checkDuplicateImage(filePath: String?): Single = - imageProcessingService.checkDuplicateImage(filePath) + fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single = + imageProcessingService.checkIfFileAlreadyExists( + originalFilePath = originalFilePath!!, + modifiedFilePath = modifiedFilePath!! + ) /** * Calls validateCaption() of ImageProcessingService to check caption of image diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt index 90d2f20e6..9a5e20b25 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/ImageProcessingServiceTest.kt @@ -1,5 +1,7 @@ package fr.free.nrw.commons.upload +import android.content.ContentResolver +import android.content.Context import android.net.Uri import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.media.MediaClient @@ -18,6 +20,7 @@ import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import java.io.FileInputStream +import java.io.InputStream class ImageProcessingServiceTest { @Mock @@ -38,6 +41,9 @@ class ImageProcessingServiceTest { @Mock internal var location: LatLng? = null + @Mock + internal lateinit var appContext: Context + @InjectMocks var imageProcessingService: ImageProcessingService? = null @@ -49,8 +55,10 @@ class ImageProcessingServiceTest { fun setUp() { MockitoAnnotations.openMocks(this) val mediaUri = mock(Uri::class.java) + val contentUri = mock(Uri::class.java) val mockPlace = mock(Place::class.java) val mockTitle = mock(List::class.java) + val contentResolver = mock(ContentResolver::class.java) `when`(mockPlace.wikiDataEntityId).thenReturn("Q1") `when`(mockPlace.getLocation()).thenReturn(mock(LatLng::class.java)) @@ -59,6 +67,8 @@ class ImageProcessingServiceTest { `when`(mockTitle.isSet).thenReturn(true)*/ `when`(uploadItem.mediaUri).thenReturn(mediaUri) + `when`(uploadItem.contentUri).thenReturn(contentUri) + `when`(appContext.contentResolver).thenReturn(contentResolver) `when`(uploadItem.imageQuality).thenReturn(ImageUtils.IMAGE_WAIT) `when`(uploadItem.uploadMediaDetails).thenReturn(mockTitle as MutableList?) @@ -68,7 +78,9 @@ class ImageProcessingServiceTest { `when`(fileUtilsWrapper!!.getFileInputStream(ArgumentMatchers.anyString())) .thenReturn(mock(FileInputStream::class.java)) - `when`(fileUtilsWrapper!!.getSHA1(any(FileInputStream::class.java))) + `when`(appContext.contentResolver.openInputStream(ArgumentMatchers.any())) + .thenReturn(mock(InputStream::class.java)) + `when`(fileUtilsWrapper!!.getSHA1(any(InputStream::class.java))) .thenReturn("fileSha") `when`(fileUtilsWrapper!!.getGeolocationOfFile(ArgumentMatchers.anyString(), any(LatLng::class.java))) From 1e77b1457ad2571b85f1014558336a0f6e20c4be Mon Sep 17 00:00:00 2001 From: Matija Nalis Date: Wed, 5 Feb 2025 07:56:57 +0100 Subject: [PATCH 13/22] Add multiline input for caption and description (#6173) * allow multiple lines for description/caption * make caption multiline too --------- Co-authored-by: Nicolas Raoul --- app/src/main/res/layout/row_item_description.xml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/row_item_description.xml b/app/src/main/res/layout/row_item_description.xml index 66cb4005c..f2e56b1d3 100644 --- a/app/src/main/res/layout/row_item_description.xml +++ b/app/src/main/res/layout/row_item_description.xml @@ -59,10 +59,12 @@ @@ -103,7 +105,9 @@ Date: Fri, 7 Feb 2025 06:33:38 +0530 Subject: [PATCH 14/22] Migrated contributions folder Files from java to kotlin (#6176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename .java to .kt * Migrated ContributionController * Rename .java to .kt * Migrated ContributionDao * Rename .java to .kt * Migrated ContributionsContract,ContributionFragment,ContributionListAdapter,ContributionsListContract from java to Kotlin * Rename .java to .kt * converted/Migrated * converted/Migrated * Rename .java to .kt * Migrated ContributionController * Rename .java to .kt * Migrated ContributionDao * Rename .java to .kt * Migrated ContributionsContract,ContributionFragment,ContributionListAdapter,ContributionsListContract from java to Kotlin * Rename .java to .kt * Show placeholder and display depiction section when no depictions are available (#6163) (#6165) * corrected * corrected * Update MediaDetailFragment.kt Spelling correction * Migrated AboutActivity from Java to Kotlin (#6158) * Rename Constants to Follow Kotlin Naming Conventions >This PR refactors constant names in the project to adhere to Kotlin's UPPERCASE_SNAKE_CASE naming convention, improving code readability and maintaining consistency across the codebase. >Renamed the following constants in LoginActivity: >saveProgressDialog → SAVE_PROGRESS_DIALOG >saveErrorMessage → SAVE_ERROR_MESSAGE >saveUsername → SAVE_USERNAME >savePassword → SAVE_PASSWORD >Updated all references to these constants throughout the project. * Update Project_Default.xml * Refactor variable names to adhere to naming conventions Renamed variables to use camel case: -UPLOAD_COUNT_THRESHOLD → uploadCountThreshold -REVERT_PERCENTAGE_FOR_MESSAGE → revertPercentageForMessage -REVERT_SHARED_PREFERENCE → revertSharedPreference -UPLOAD_SHARED_PREFERENCE → uploadSharedPreference Renamed variables with uppercase initials to lowercase for alignment with Kotlin conventions: -Latitude → latitude -Longitude → longitude -Accuracy → accuracy Refactored the following variable names: -NUMBER_OF_QUESTIONS → numberOfQuestions -MULTIPLIER_TO_GET_PERCENTAGE → multiplierToGetPercentage * Refactor Dialog View Initialization with Null-Safe Calls This PR refactors the dialog setup code in CustomSelectorActivity to improve safety and readability by replacing explicit casts with null-safe generic calls for findViewById. >Replaced explicit casting (as Button and as TextView) with the generic findViewById() method for improved type safety. >Added null-safety (?.) to avoid potential crashes if a view is not found in the dialog layout. why changed:- >Prevents runtime crashes caused by NullPointerException when a view is missing in the layout. * Refactor Unit Test: Replace Unsafe Casting with Type-Safe Mocking for findViewById >PR refactors the unit test for NearbyParentFragment by replacing unsafe casting in the findViewById mocking statements with type-safe >Ensured all findViewById mocks now use a consistent, type-safe format (findViewById(...)) to reduce verbosity and potential casting errors. >Verified the functionality of prepareViewsForSheetPosition remains unchanged, ensuring no regression in test behavior. * Update NearbyParentFragmentUnitTest.kt * Refactor: Rename Constants to Follow CamelCase Naming Convention >Updated all constant variable names to follow the camelCase naming convention, removing underscores in the middle or end. >Ensured variable names remain descriptive and align with code readability best practices. * Replace private val with const val for URL constants in QuizController * Renaming the constant to use UPPER_SNAKE_CASE * Renaming the constant to use UPPER_SNAKE_CASE * Update Done * **Refactor: Convert `minimumThresholdForSwipe` to a compile-time constant** * Convert AboutActivity from Java to Kotlin This PR converts the AboutActivity class from Java to Kotlin >Testing: >Verified all functionalities of the AboutActivity, including toolbar setup, intent launches, and dialog interactions, to ensure behavior remains consistent post-conversion. >Successfully ran unit tests for AboutActivity to confirm the correctness of methods and logic. * Thank you for the suggestion! Since these methods all take a single View parameter, replacing them with method references is a great way to simplify the code and improve readability. I'll updated the code accordingly. Added a TODO in the code as a reminder to refactor this in the future. --------- Co-authored-by: Nicolas Raoul * Localisation updates from https://translatewiki.net. * Feat: Make it smoother to switch between nearby and explore maps (#6164) * Nearby: Add 'Show in Explore' 3-dots menu item * MainActivity: Add methods to pass extras between Nearby and Explore * MainActivity: Extend loadFragment() to support passing fragment arguments * Nearby: Add ability to navigate to Explore fragment on 'Show in Explore' click * Explore: Read fragment arguments for Nearby map data and update Explore map if present * Explore: Add 'Show in Nearby' 3-dots menu item. Only visible when Map tab is selected * Explore: On 'Show in Nearby' click, navigate to Nearby fragment, passing map data as fragment args * Nearby: Read fragment arguments for Explore map data and update Nearby map if present * MainActivity: Fix memory leaks when navigating between bottom nav destinations * Explore: Fix crashes caused by unattached map fragment * Refactor code to pass unit tests * Explore: Format javadocs --------- Co-authored-by: Nicolas Raoul * Localisation updates from https://translatewiki.net. * enhance spammy category filter (#6167) Signed-off-by: parneet-guraya * Localisation updates from https://translatewiki.net. * correction * correction * correction * GitHub workflow to build betaDebug (#6174) * [Bug fix] Check if duplicate exist using both original and modified file's checksum (#6169) * check original file's SHA too along with modified one Signed-off-by: parneet-guraya * fix tests Signed-off-by: parneet-guraya --------- Signed-off-by: parneet-guraya * Add multiline input for caption and description (#6173) * allow multiple lines for description/caption * make caption multiline too --------- Co-authored-by: Nicolas Raoul * correction --------- Signed-off-by: parneet-guraya Co-authored-by: Akshay Komar <146421342+Akshaykomar890@users.noreply.github.com> Co-authored-by: Nicolas Raoul Co-authored-by: translatewiki.net Co-authored-by: Ifeoluwa Andrew Omole Co-authored-by: Parneet Singh <111801812+parneet-guraya@users.noreply.github.com> Co-authored-by: Matija Nalis --- .../contributions/ContributionController.java | 405 ------- .../contributions/ContributionController.kt | 474 +++++++++ .../contributions/ContributionDao.java | 145 --- .../commons/contributions/ContributionDao.kt | 148 +++ .../contributions/ContributionViewHolder.java | 171 --- .../contributions/ContributionViewHolder.kt | 152 +++ .../contributions/ContributionsContract.java | 23 - .../contributions/ContributionsContract.kt | 19 + .../contributions/ContributionsFragment.java | 940 ----------------- .../contributions/ContributionsFragment.kt | 998 ++++++++++++++++++ .../ContributionsListAdapter.java | 77 -- .../contributions/ContributionsListAdapter.kt | 72 ++ .../ContributionsListContract.java | 25 - .../ContributionsListContract.kt | 21 + .../ContributionsListFragment.java | 534 ---------- .../ContributionsListFragment.kt | 551 ++++++++++ .../ContributionsListPresenter.java | 112 -- .../ContributionsListPresenter.kt | 91 ++ .../ContributionsLocalDataSource.java | 131 --- .../ContributionsLocalDataSource.kt | 121 +++ .../contributions/ContributionsModule.java | 15 - .../contributions/ContributionsModule.kt | 16 + .../contributions/ContributionsPresenter.java | 97 -- .../contributions/ContributionsPresenter.kt | 88 ++ .../ContributionsProvidesModule.kt | 28 + .../ContributionsRepository.java | 112 -- .../contributions/ContributionsRepository.kt | 102 ++ .../commons/contributions/MainActivity.java | 550 ---------- .../nrw/commons/contributions/MainActivity.kt | 567 ++++++++++ .../contributions/SetWallpaperWorker.java | 126 --- .../contributions/SetWallpaperWorker.kt | 113 ++ .../contributions/UnswipableViewPager.java | 31 - .../contributions/UnswipableViewPager.kt | 22 + .../WikipediaInstructionsDialogFragment.kt | 2 +- .../commons/di/CommonsApplicationComponent.kt | 2 + .../di/CommonsDaggerSupportFragment.kt | 9 +- .../explore/map/ExploreMapFragment.java | 2 +- .../free/nrw/commons/filepicker/FilePicker.kt | 2 +- .../nrw/commons/media/MediaDetailFragment.kt | 2 +- .../free/nrw/commons/navtab/NavTabLayout.kt | 4 +- .../fragments/NearbyParentFragment.java | 20 +- .../commons/repository/UploadRepository.kt | 2 +- .../commons/upload/PendingUploadsPresenter.kt | 13 +- .../free/nrw/commons/upload/UploadActivity.kt | 2 +- .../categories/UploadCategoriesFragment.kt | 5 +- .../nrw/commons/upload/worker/UploadWorker.kt | 2 +- .../ContributionViewHolderUnitTests.kt | 9 +- .../ContributionsListFragmentUnitTests.kt | 2 +- .../contributions/MainActivityUnitTests.kt | 4 +- 49 files changed, 3630 insertions(+), 3529 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionDao.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionViewHolder.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsContract.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsLocalDataSource.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsModule.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsProvidesModule.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/ContributionsRepository.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java create mode 100644 app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java deleted file mode 100644 index 65604a7e0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ /dev/null @@ -1,405 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - -import android.Manifest.permission; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.widget.Toast; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.paging.DataSource.Factory; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.filepicker.DefaultCallback; -import fr.free.nrw.commons.filepicker.FilePicker; -import fr.free.nrw.commons.filepicker.FilePicker.ImageSource; -import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.location.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.UploadActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -@Singleton -public class ContributionController { - - public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads"; - private final JsonKvStore defaultKvStore; - private LatLng locationBeforeImageCapture; - private boolean isInAppCameraUpload; - public LocationPermissionCallback locationPermissionCallback; - private LocationPermissionsHelper locationPermissionsHelper; - // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - // LiveData> failedAndPendingContributionList; - LiveData> pendingContributionList; - LiveData> failedContributionList; - - @Inject - LocationServiceManager locationManager; - - @Inject - ContributionsRepository repository; - - @Inject - public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) { - this.defaultKvStore = defaultKvStore; - } - - /** - * Check for permissions and initiate camera click - */ - public void initiateCameraPick(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); - if (!useExtStorage) { - initiateCameraUpload(activity, resultLauncher); - return; - } - - PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> { - if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { - defaultKvStore.putBoolean("inAppCameraFirstRun", false); - askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher); - } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - }, - R.string.storage_permission_title, - R.string.write_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE()); - } - - /** - * Asks users to provide location access - * - * @param activity - */ - private void createDialogsAndHandleLocationPermissions(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - locationPermissionCallback = new LocationPermissionCallback() { - @Override - public void onLocationPermissionDenied(String toastMessage) { - Toast.makeText( - activity, - toastMessage, - Toast.LENGTH_LONG - ).show(); - initiateCameraUpload(activity, resultLauncher); - } - - @Override - public void onLocationPermissionGranted() { - if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { - showLocationOffDialog(activity, R.string.in_app_camera_needs_location, - R.string.in_app_camera_location_unavailable, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - } - }; - - locationPermissionsHelper = new LocationPermissionsHelper( - activity, locationManager, locationPermissionCallback); - if (inAppCameraLocationPermissionLauncher != null) { - inAppCameraLocationPermissionLauncher.launch( - new String[]{permission.ACCESS_FINE_LOCATION}); - } - - } - - /** - * Shows a dialog alerting the user about location services being off and asking them to turn it - * on - * TODO: Add a seperate callback in LocationPermissionsHelper for this. - * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 - * - * @param activity Activity reference - * @param dialogTextResource Resource id of text to be shown in dialog - * @param toastTextResource Resource id of text to be shown in toast - * @param resultLauncher - */ - private void showLocationOffDialog(Activity activity, int dialogTextResource, - int toastTextResource, ActivityResultLauncher resultLauncher) { - DialogUtil - .showAlertDialog(activity, - activity.getString(R.string.ask_to_turn_location_on), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> locationPermissionsHelper.openLocationSettings(activity), - () -> { - Toast.makeText(activity, activity.getString(toastTextResource), - Toast.LENGTH_LONG).show(); - initiateCameraUpload(activity, resultLauncher); - } - ); - } - - public void handleShowRationaleFlowCameraLocation(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), - activity.getString(R.string.in_app_camera_location_permission_rationale), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - }, - () -> locationPermissionCallback.onLocationPermissionDenied( - activity.getString(R.string.in_app_camera_location_permission_denied)), - null - ); - } - - /** - * Suggest user to attach location information with pictures. If the user selects "Yes", then: - *

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

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

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

- * When the app is terminated or the device is restarted, contributions remain in the - * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, - * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the - * list of uploads that appear as stuck on opening the app again - */ - @SuppressLint("CheckResult") - private void checkAndResumeStuckUploads() { - List stuckUploads = contributionDao.getContribution( - Collections.singletonList(Contribution.STATE_IN_PROGRESS)) - .subscribeOn(Schedulers.io()) - .blockingGet(); - Timber.d("Resuming " + stuckUploads.size() + " uploads..."); - if (!stuckUploads.isEmpty()) { - for (Contribution contribution : stuckUploads) { - contribution.setState(Contribution.STATE_QUEUED); - contribution.setDateUploadStarted(Calendar.getInstance().getTime()); - Completable.fromAction(() -> contributionDao.saveSynchronous(contribution)) - .subscribeOn(Schedulers.io()) - .subscribe(); - } - WorkRequestHelper.Companion.makeOneTimeWorkRequest( - this, ExistingWorkPolicy.APPEND_OR_REPLACE); - } - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - //quizChecker.initQuizCheck(this); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("viewPagerCurrentItem", binding.pager.getCurrentItem()); - outState.putString("activeFragment", activeFragment.name()); - } - - @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - String activeFragmentName = savedInstanceState.getString("activeFragment"); - if (activeFragmentName != null) { - restoreActiveFragment(activeFragmentName); - } - } - - private void restoreActiveFragment(@NonNull String fragmentName) { - if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) { - setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.NEARBY.name())) { - setTitle(getString(R.string.nearby_fragment)); - loadFragment(NearbyParentFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) { - setTitle(getString(R.string.navigation_item_explore)); - loadFragment(ExploreFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) { - setTitle(getString(R.string.bookmarks)); - loadFragment(BookmarkFragment.newInstance(), false); - } - } - - @Override - public void onBackPressed() { - if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { - // Means that contribution fragment is visible - if (!contributionsFragment.backButtonClicked()) {//If this one does not wan't to handle - // the back press, let the activity do so - super.onBackPressed(); - } - } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { - // Means that nearby fragment is visible - /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is - not expanded. So if the back button is pressed, then go back to the Contributions tab */ - if (!nearbyParentFragment.backButtonClicked()) { - getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment) - .commit(); - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { - // Means that explore fragment is visible - if (!exploreFragment.onBackPressed()) { - if (applicationKvStore.getBoolean("login_skipped")) { - super.onBackPressed(); - } else { - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } - } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { - // Means that bookmark fragment is visible - bookmarkFragment.onBackPressed(); - } else { - super.onBackPressed(); - } - } - - @Override - public void onBackStackChanged() { - //initBackButton(); - } - - /** - * Retry all failed uploads as soon as the user returns to the app - */ - @SuppressLint("CheckResult") - private void retryAllFailedUploads() { - contributionDao. - getContribution(Collections.singletonList(Contribution.STATE_FAILED)) - .subscribeOn(Schedulers.io()) - .subscribe(failedUploads -> { - for (Contribution contribution : failedUploads) { - contributionsFragment.retryUpload(contribution); - } - }); - } - - /** - * Handles item selection in the options menu. This method is called when a user interacts with - * the options menu in the Top Bar. - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.upload_tab: - startActivity(new Intent(this, UploadProgressActivity.class)); - return true; - case R.id.notifications: - // Starts notification activity on click to notification icon - NotificationActivity.Companion.startYourself(this, "unread"); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void centerMapToPlace(Place place) { - setSelectedItemId(NavTab.NEARBY.code()); - nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback( - new NearbyParentFragmentInstanceReadyCallback() { - @Override - public void onReady() { - nearbyParentFragment.centerMapToPlace(place); - } - }); - } - - /** - * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks - * the 'Show in Explore' option in the 3-dots menu in Nearby. - * - * @param zoom current zoom of Nearby map - * @param latitude current latitude of Nearby map - * @param longitude current longitude of Nearby map - **/ - public void loadExploreMapFromNearby(double zoom, double latitude, double longitude) { - Bundle bundle = new Bundle(); - bundle.putDouble("prev_zoom", zoom); - bundle.putDouble("prev_latitude", latitude); - bundle.putDouble("prev_longitude", longitude); - - loadFragment(ExploreFragment.newInstance(), false, bundle); - setSelectedItemId(NavTab.EXPLORE.code()); - } - - /** - * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks - * the 'Show in Nearby' option in the 3-dots menu in Explore. - * - * @param zoom current zoom of Explore map - * @param latitude current latitude of Explore map - * @param longitude current longitude of Explore map - **/ - public void loadNearbyMapFromExplore(double zoom, double latitude, double longitude) { - Bundle bundle = new Bundle(); - bundle.putDouble("prev_zoom", zoom); - bundle.putDouble("prev_latitude", latitude); - bundle.putDouble("prev_longitude", longitude); - - loadFragment(NearbyParentFragment.newInstance(), false, bundle); - setSelectedItemId(NavTab.NEARBY.code()); - } - - @Override - protected void onResume() { - super.onResume(); - - if ((applicationKvStore.getBoolean("firstrun", true)) && - (!applicationKvStore.getBoolean("login_skipped"))) { - defaultKvStore.putBoolean("inAppCameraFirstRun", true); - WelcomeActivity.startYourself(this); - } - - retryAllFailedUploads(); - } - - @Override - protected void onDestroy() { - quizChecker.cleanup(); - locationManager.unregisterLocationManager(); - // Remove ourself from hashmap to prevent memory leaks - locationManager = null; - super.onDestroy(); - } - - /** - * Public method to show nearby from the reference of this. - */ - public void showNearby() { - binding.fragmentMainNavTabLayout.setSelectedItemId(NavTab.NEARBY.code()); - } - - public enum ActiveFragment { - CONTRIBUTIONS, - NEARBY, - EXPLORE, - BOOKMARK, - MORE - } - - /** - * Load default language in onCreate from SharedPreferences - */ - private void loadLocale() { - final SharedPreferences preferences = getSharedPreferences("Settings", - Activity.MODE_PRIVATE); - final String language = preferences.getString("language", ""); - final SettingsFragment settingsFragment = new SettingsFragment(); - settingsFragment.setLocale(this, language); - } - - public NavTabLayout.OnNavigationItemSelectedListener getNavListener() { - return navListener; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt new file mode 100644 index 000000000..8d6efd664 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -0,0 +1,567 @@ +package fr.free.nrw.commons.contributions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.work.ExistingWorkPolicy +import com.google.android.material.bottomnavigation.BottomNavigationView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.WelcomeActivity +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.contributions.ContributionsFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.MainBinding +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment +import fr.free.nrw.commons.navtab.NavTab +import fr.free.nrw.commons.navtab.NavTabLayout +import fr.free.nrw.commons.navtab.NavTabLoggedOut +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +import fr.free.nrw.commons.notification.NotificationController +import fr.free.nrw.commons.quiz.QuizChecker +import fr.free.nrw.commons.settings.SettingsFragment +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Completable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + + +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + @JvmField + @Inject + var sessionManager: SessionManager? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var contributionDao: ContributionDao? = null + + private var contributionsFragment: ContributionsFragment? = null + private var nearbyParentFragment: NearbyParentFragment? = null + private var exploreFragment: ExploreFragment? = null + private var bookmarkFragment: BookmarkFragment? = null + @JvmField + var activeFragment: ActiveFragment? = null + private val mediaDetailPagerFragment: MediaDetailPagerFragment? = null + var navListener: BottomNavigationView.OnNavigationItemSelectedListener? = null + private set + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var quizChecker: QuizChecker? = null + + @JvmField + @Inject + @Named("default_preferences") + var applicationKvStore: JsonKvStore? = null + + @JvmField + @Inject + var viewUtilWrapper: ViewUtilWrapper? = null + + var menu: Menu? = null + + @JvmField + var binding: MainBinding? = null + + var tabLayout: NavTabLayout? = null + + + override fun onSupportNavigateUp(): Boolean { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + if (!contributionsFragment!!.backButtonClicked()) { + return false + } + } else { + onBackPressed() + showTabs() + } + return true + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = MainBinding.inflate(layoutInflater) + setContentView(binding!!.root) + setSupportActionBar(binding!!.toolbarBinding.toolbar) + tabLayout = binding!!.fragmentMainNavTabLayout + loadLocale() + + binding!!.toolbarBinding.toolbar.setNavigationOnClickListener { view: View? -> + onSupportNavigateUp() + } + /* +"first_edit_depict" is a key for getting information about opening the depiction editor +screen for the first time after opening the app. + +Getting true by the key means the depiction editor screen is opened for the first time +after opening the app. +Getting false by the key means the depiction editor screen is not opened for the first time +after opening the app. + */ + applicationKvStore!!.putBoolean("first_edit_depict", true) + if (applicationKvStore!!.getBoolean("login_skipped") == true) { + title = getString(R.string.navigation_item_explore) + setUpLoggedOutPager() + } else { + if (applicationKvStore!!.getBoolean("firstrun", true)) { + applicationKvStore!!.putBoolean("hasAlreadyLaunchedBigMultiupload", false) + applicationKvStore!!.putBoolean("hasAlreadyLaunchedCategoriesDialog", false) + } + if (savedInstanceState == null) { + //starting a fresh fragment. + // Open Last opened screen if it is Contributions or Nearby, otherwise Contributions + if (applicationKvStore!!.getBoolean("last_opened_nearby")) { + title = getString(R.string.nearby_fragment) + showNearby() + loadFragment(NearbyParentFragment.newInstance(), false) + } else { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } + } + setUpPager() + /** + * Ask the user for media location access just after login + * so that location in the EXIF metadata of the images shared by the user + * is retained on devices running Android 10 or above + */ +// if (VERSION.SDK_INT >= VERSION_CODES.Q) { +// ActivityCompat.requestPermissions(this, +// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0); +// PermissionUtils.checkPermissionsAndPerformAction( +// this, +// () -> {}, +// R.string.media_location_permission_denied, +// R.string.add_location_manually, +// permission.ACCESS_MEDIA_LOCATION); +// } + checkAndResumeStuckUploads() + } + } + + fun setSelectedItemId(id: Int) { + binding!!.fragmentMainNavTabLayout.selectedItemId = id + } + + private fun setUpPager() { + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( + BottomNavigationView.OnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + // set last_opened_nearby true if item is nearby screen else set false + applicationKvStore!!.putBoolean( + "last_opened_nearby", + item.title == getString(R.string.nearby_fragment) + ) + val fragment = NavTab.of(item.order).newInstance() + loadFragment(fragment, true) + }.also { navListener = it }) + } + + private fun setUpLoggedOutPager() { + loadFragment(ExploreFragment.newInstance(), false) + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + val fragment = + NavTabLoggedOut.of(item.order).newInstance() + loadFragment(fragment, true) + } + } + + private fun loadFragment(fragment: Fragment?, showBottom: Boolean): Boolean { + //showBottom so that we do not show the bottom tray again when constructing + //from the saved instance state. + + freeUpFragments(); + + if (fragment is ContributionsFragment) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + // scroll to top if already on the Contributions tab + contributionsFragment!!.scrollToTop() + return true + } + contributionsFragment = fragment + activeFragment = ActiveFragment.CONTRIBUTIONS + } else if (fragment is NearbyParentFragment) { + if (activeFragment == ActiveFragment.NEARBY) { // Do nothing if same tab + return true + } + nearbyParentFragment = fragment + activeFragment = ActiveFragment.NEARBY + } else if (fragment is ExploreFragment) { + if (activeFragment == ActiveFragment.EXPLORE) { // Do nothing if same tab + return true + } + exploreFragment = fragment + activeFragment = ActiveFragment.EXPLORE + } else if (fragment is BookmarkFragment) { + if (activeFragment == ActiveFragment.BOOKMARK) { // Do nothing if same tab + return true + } + bookmarkFragment = fragment + activeFragment = ActiveFragment.BOOKMARK + } else if (fragment == null && showBottom) { + if (applicationKvStore!!.getBoolean("login_skipped") + == true + ) { // If logged out, more sheet is different + val bottomSheet = MoreBottomSheetLoggedOutFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheetLoggedOut" + ) + } else { + val bottomSheet = MoreBottomSheetFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheet" + ) + } + } + + if (fragment != null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragmentContainer, fragment) + .commit() + return true + } + return false + } + + /** + * loadFragment() overload that supports passing extras to fragments + */ + private fun loadFragment(fragment: Fragment?, showBottom: Boolean, args: Bundle?): Boolean { + if (fragment != null && args != null) { + fragment.arguments = args + } + + return loadFragment(fragment, showBottom) + } + + /** + * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding + * references to cleared fragments. This function frees up all fragment references. + * + * + * Called in loadFragment() before doing the actual loading. + */ + fun freeUpFragments() { + // free all fragments except contributionsFragment because several tests depend on it. + // hence, contributionsFragment is probably still a leak + nearbyParentFragment = null + exploreFragment = null + bookmarkFragment = null + } + + + fun hideTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.GONE + } + + fun showTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.VISIBLE + } + + /** + * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions + * (NUMBER)" + * + * @param uploadCount + */ + fun setNumOfUploads(uploadCount: Int) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + title = + resources.getString(R.string.contributions_fragment) + " " + (if (uploadCount != 0) + resources + .getQuantityString( + R.plurals.contributions_subtitle, + uploadCount, uploadCount + ) + else + getString(R.string.contributions_subtitle_zero)) + } + } + + /** + * Resume the uploads that got stuck because of the app being killed or the device being + * rebooted. + * + * + * When the app is terminated or the device is restarted, contributions remain in the + * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, + * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the + * list of uploads that appear as stuck on opening the app again + */ + @SuppressLint("CheckResult") + private fun checkAndResumeStuckUploads() { + val stuckUploads = contributionDao!!.getContribution( + listOf(Contribution.STATE_IN_PROGRESS) + ) + .subscribeOn(Schedulers.io()) + .blockingGet() + Timber.d("Resuming " + stuckUploads.size + " uploads...") + if (!stuckUploads.isEmpty()) { + for (contribution in stuckUploads) { + contribution.state = Contribution.STATE_QUEUED + contribution.dateUploadStarted = Calendar.getInstance().time + Completable.fromAction { contributionDao!!.saveSynchronous(contribution) } + .subscribeOn(Schedulers.io()) + .subscribe() + } + makeOneTimeWorkRequest( + this, ExistingWorkPolicy.APPEND_OR_REPLACE + ) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + //quizChecker.initQuizCheck(this); + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("viewPagerCurrentItem", binding!!.pager.currentItem) + outState.putString("activeFragment", activeFragment!!.name) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val activeFragmentName = savedInstanceState.getString("activeFragment") + if (activeFragmentName != null) { + restoreActiveFragment(activeFragmentName) + } + } + + private fun restoreActiveFragment(fragmentName: String) { + if (fragmentName == ActiveFragment.CONTRIBUTIONS.name) { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } else if (fragmentName == ActiveFragment.NEARBY.name) { + title = getString(R.string.nearby_fragment) + loadFragment(NearbyParentFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.EXPLORE.name) { + title = getString(R.string.navigation_item_explore) + loadFragment(ExploreFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.BOOKMARK.name) { + title = getString(R.string.bookmarks) + loadFragment(BookmarkFragment.newInstance(), false) + } + } + + override fun onBackPressed() { + if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { + // Means that contribution fragment is visible + if (!contributionsFragment!!.backButtonClicked()) { //If this one does not wan't to handle + // the back press, let the activity do so + super.onBackPressed() + } + } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { + // Means that nearby fragment is visible + /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is + not expanded. So if the back button is pressed, then go back to the Contributions tab */ + if (!nearbyParentFragment!!.backButtonClicked()) { + supportFragmentManager.beginTransaction().remove(nearbyParentFragment!!) + .commit() + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { + // Means that explore fragment is visible + if (!exploreFragment!!.onBackPressed()) { + if (applicationKvStore!!.getBoolean("login_skipped")) { + super.onBackPressed() + } else { + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } + } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { + // Means that bookmark fragment is visible + bookmarkFragment!!.onBackPressed() + } else { + super.onBackPressed() + } + } + + override fun onBackStackChanged() { + //initBackButton(); + } + + /** + * Retry all failed uploads as soon as the user returns to the app + */ + @SuppressLint("CheckResult") + private fun retryAllFailedUploads() { + contributionDao + ?.getContribution(listOf(Contribution.STATE_FAILED)) + ?.subscribeOn(Schedulers.io()) + ?.subscribe { failedUploads -> + failedUploads.forEach { contribution -> + contributionsFragment?.retryUpload(contribution) + } + } + } + + /** + * Handles item selection in the options menu. This method is called when a user interacts with + * the options menu in the Top Bar. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.upload_tab -> { + startActivity(Intent(this, UploadProgressActivity::class.java)) + return true + } + + R.id.notifications -> { + // Starts notification activity on click to notification icon + startYourself(this, "unread") + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun centerMapToPlace(place: Place?) { + setSelectedItemId(NavTab.NEARBY.code()) + nearbyParentFragment!!.setNearbyParentFragmentInstanceReadyCallback { + nearbyParentFragment!!.centerMapToPlace( + place + ) + } + } + + /** + * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks + * the 'Show in Explore' option in the 3-dots menu in Nearby. + * + * @param zoom current zoom of Nearby map + * @param latitude current latitude of Nearby map + * @param longitude current longitude of Nearby map + */ + fun loadExploreMapFromNearby(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(ExploreFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.EXPLORE.code()) + } + + /** + * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks + * the 'Show in Nearby' option in the 3-dots menu in Explore. + * + * @param zoom current zoom of Explore map + * @param latitude current latitude of Explore map + * @param longitude current longitude of Explore map + */ + fun loadNearbyMapFromExplore(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(NearbyParentFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.NEARBY.code()) + } + + override fun onResume() { + super.onResume() + + if ((applicationKvStore!!.getBoolean("firstrun", true)) && + (!applicationKvStore!!.getBoolean("login_skipped")) + ) { + defaultKvStore.putBoolean("inAppCameraFirstRun", true) + WelcomeActivity.startYourself(this) + } + + retryAllFailedUploads() + } + + override fun onDestroy() { + quizChecker!!.cleanup() + locationManager!!.unregisterLocationManager() + // Remove ourself from hashmap to prevent memory leaks + locationManager = null + super.onDestroy() + } + + /** + * Public method to show nearby from the reference of this. + */ + fun showNearby() { + binding!!.fragmentMainNavTabLayout.selectedItemId = NavTab.NEARBY.code() + } + + enum class ActiveFragment { + CONTRIBUTIONS, + NEARBY, + EXPLORE, + BOOKMARK, + MORE + } + + /** + * Load default language in onCreate from SharedPreferences + */ + private fun loadLocale() { + val preferences = getSharedPreferences( + "Settings", + MODE_PRIVATE + ) + val language = preferences.getString("language", "")!! + val settingsFragment = SettingsFragment() + settingsFragment.setLocale(this, language) + } + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * + * @param context A Context of the application package implementing this class. + */ + fun startYourself(context: Context) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java deleted file mode 100644 index 0f18c300b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java +++ /dev/null @@ -1,126 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.WallpaperManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Build; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.R; -import timber.log.Timber; - -public class SetWallpaperWorker extends Worker { - - private static final String NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel"; - private static final int NOTIFICATION_ID = 1; - - public SetWallpaperWorker(@NonNull Context context, @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - Context context = getApplicationContext(); - createNotificationChannel(context); - showProgressNotification(context); - - String imageUrl = getInputData().getString("imageUrl"); - if (imageUrl == null) { - return Result.failure(); - } - - ImageRequest imageRequest = ImageRequestBuilder - .newBuilderWithSource(Uri.parse(imageUrl)) - .build(); - - ImagePipeline imagePipeline = Fresco.getImagePipeline(); - final DataSource> - dataSource = imagePipeline.fetchDecodedImage(imageRequest, context); - - dataSource.subscribe(new BaseBitmapDataSubscriber() { - @Override - public void onNewResultImpl(@Nullable Bitmap bitmap) { - if (dataSource.isFinished() && bitmap != null) { - Timber.d("Bitmap loaded from url %s", imageUrl.toString()); - setWallpaper(context, Bitmap.createBitmap(bitmap)); - dataSource.close(); - } - } - - @Override - public void onFailureImpl(DataSource dataSource) { - Timber.d("Error getting bitmap from image url %s", imageUrl.toString()); - showNotification(context, "Setting Wallpaper Failed", "Failed to download image."); - if (dataSource != null) { - dataSource.close(); - } - } - }, CallerThreadExecutor.getInstance()); - - return Result.success(); - } - - private void setWallpaper(Context context, Bitmap bitmap) { - WallpaperManager wallpaperManager = WallpaperManager.getInstance(context); - - try { - wallpaperManager.setBitmap(bitmap); - showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully."); - - } catch (Exception e) { - Timber.e(e, "Error setting wallpaper"); - showNotification(context, "Setting Wallpaper Failed", " "+e.getLocalizedMessage()); - } - } - - private void showProgressNotification(Context context) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle("Setting Wallpaper") - .setContentText("Please wait...") - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(true) - .setProgress(0, 0, true); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void showNotification(Context context, String title, String content) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle(title) - .setContentText(content) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(false); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void createNotificationChannel(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = "Wallpaper Setting"; - String description = "Notifications for wallpaper setting progress"; - int importance = NotificationManager.IMPORTANCE_HIGH; - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance); - channel.setDescription(description); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt new file mode 100644 index 000000000..06c31fede --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -0,0 +1,113 @@ +package fr.free.nrw.commons.contributions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.WallpaperManager +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.R +import timber.log.Timber + +class SetWallpaperWorker(context: Context, params: WorkerParameters) : + Worker(context, params) { + override fun doWork(): Result { + val context = applicationContext + createNotificationChannel(context) + showProgressNotification(context) + + val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() + + val imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(imageUrl)) + .build() + + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + public override fun onNewResultImpl(bitmap: Bitmap?) { + if (dataSource.isFinished && bitmap != null) { + Timber.d("Bitmap loaded from url %s", imageUrl.toString()) + setWallpaper(context, Bitmap.createBitmap(bitmap)) + dataSource.close() + } + } + + override fun onFailureImpl(dataSource: DataSource>?) { + Timber.d("Error getting bitmap from image url %s", imageUrl.toString()) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") + dataSource?.close() + } + }, CallerThreadExecutor.getInstance()) + + return Result.success() + } + + private fun setWallpaper(context: Context, bitmap: Bitmap) { + val wallpaperManager = WallpaperManager.getInstance(context) + + try { + wallpaperManager.setBitmap(bitmap) + showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.") + } catch (e: Exception) { + Timber.e(e, "Error setting wallpaper") + showNotification(context, "Setting Wallpaper Failed", " " + e.localizedMessage) + } + } + + private fun showProgressNotification(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle("Setting Wallpaper") + .setContentText("Please wait...") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setProgress(0, 0, true) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun showNotification(context: Context, title: String, content: String) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Wallpaper Setting" + val description = "Notifications for wallpaper setting progress" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + companion object { + private const val NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java deleted file mode 100644 index 898a36a99..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.ViewPager; - -public class UnswipableViewPager extends ViewPager{ - public UnswipableViewPager(@NonNull Context context) { - super(context); - } - - public UnswipableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent event) { - // Unswipable - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - // Unswipable - return false; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt new file mode 100644 index 000000000..dd6ae661a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class UnswipableViewPager : ViewPager { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index 86cda2cf3..f16a48b4c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -43,7 +43,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { /** * Callback for handling confirm button clicked */ - interface Callback { + fun interface Callback { fun onConfirmClicked( contribution: Contribution?, copyWikicode: Boolean, diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt index 94319060b..9e569982f 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt @@ -9,6 +9,7 @@ import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.activity.SingleWebViewActivity import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.contributions.ContributionsModule +import fr.free.nrw.commons.contributions.ContributionsProvidesModule import fr.free.nrw.commons.explore.SearchModule import fr.free.nrw.commons.explore.categories.CategoriesModule import fr.free.nrw.commons.explore.depictions.DepictionModule @@ -40,6 +41,7 @@ import javax.inject.Singleton ContentProviderBuilderModule::class, UploadModule::class, ContributionsModule::class, + ContributionsProvidesModule::class, SearchModule::class, DepictionModule::class, CategoriesModule::class diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt index 8204d4415..5468cfa10 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt @@ -15,8 +15,8 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje @Inject @JvmField var childFragmentInjector: DispatchingAndroidInjector? = null - @JvmField - protected var compositeDisposable: CompositeDisposable = CompositeDisposable() + // Removed @JvmField to allow overriding + protected open var compositeDisposable: CompositeDisposable = CompositeDisposable() override fun onAttach(context: Context) { inject() @@ -63,4 +63,9 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje return getInstance(activity.applicationContext) } + + // Ensure getContext() returns a non-null Context + override fun getContext(): Context { + return super.getContext() ?: throw IllegalStateException("Context is null") + } } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index e64b96190..1b1659182 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -467,7 +467,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(getLastMapFocus(), currentLatLng, false); } - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(explorePlacesInfo -> { diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt index 2ed573740..acf072f02 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -426,7 +426,7 @@ object FilePicker : Constants { fun onCanceled(source: ImageSource, type: Int) } - interface HandleActivityResult { + fun interface HandleActivityResult { fun onHandleActivityResult(callbacks: Callbacks) } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index d714b094a..40c9785db 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -493,7 +493,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C val contributionsFragment: ContributionsFragment? = this.getContributionsFragmentParent() if (contributionsFragment?.binding != null) { - contributionsFragment.binding.cardViewNearby.visibility = View.GONE + contributionsFragment.binding!!.cardViewNearby.visibility = View.GONE } // detail provider is null when fragment is shown in review activity diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt index 8d5298cac..73d030ed0 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt @@ -31,8 +31,8 @@ class NavTabLayout : BottomNavigationView { private fun setTabViews() { val isLoginSkipped = (context as MainActivity) - .applicationKvStore.getBoolean("login_skipped") - if (isLoginSkipped) { + .applicationKvStore?.getBoolean("login_skipped") + if (isLoginSkipped == true) { for (i in 0 until NavTabLoggedOut.size()) { val navTab = NavTabLoggedOut.of(i) menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()) diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java index 95f19f699..2b64f0e37 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java @@ -742,7 +742,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment public void onPause() { super.onPause(); binding.map.onPause(); - compositeDisposable.clear(); + getCompositeDisposable().clear(); presenter.detachView(); registerUnregisterLocationListener(true); try { @@ -857,7 +857,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment 0.75); binding.nearbyFilterList.searchListView.setAdapter(nearbyFilterSearchRecyclerViewAdapter); LayoutUtils.setLayoutHeightAlignedToWidth(1.25, binding.nearbyFilterList.getRoot()); - compositeDisposable.add( + getCompositeDisposable().add( RxSearchView.queryTextChanges(binding.nearbyFilter.searchViewLayout.searchView) .takeUntil(RxView.detaches(binding.nearbyFilter.searchViewLayout.searchView)) .debounce(500, TimeUnit.MILLISECONDS) @@ -1234,7 +1234,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment */ private void emptyCache() { // reload the map once the cache is cleared - compositeDisposable.add( + getCompositeDisposable().add( placesRepository.clearCache() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -1269,7 +1269,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable savePlacesObservable = Observable .fromCallable(() -> nearbyController .getPlacesAsKML(getMapFocus())); - compositeDisposable.add(savePlacesObservable + getCompositeDisposable().add(savePlacesObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(kmlString -> { @@ -1303,7 +1303,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable savePlacesObservable = Observable .fromCallable(() -> nearbyController .getPlacesAsGPX(getMapFocus())); - compositeDisposable.add(savePlacesObservable + getCompositeDisposable().add(savePlacesObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(gpxString -> { @@ -1405,7 +1405,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment final Observable> getPlaceObservable = Observable .fromCallable(() -> nearbyController .getPlaces(List.of(place))); - compositeDisposable.add(getPlaceObservable + getCompositeDisposable().add(getPlaceObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(placeList -> { @@ -1449,7 +1449,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment searchLatLng, false, true, Utils.isMonumentsEnabled(new Date()), customQuery)); - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(nearbyPlacesInfo -> { @@ -1486,7 +1486,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment searchLatLng, false, true, Utils.isMonumentsEnabled(new Date()), customQuery)); - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(nearbyPlacesInfo -> { @@ -1518,7 +1518,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment } public void savePlaceToDatabase(Place place) { - compositeDisposable.add(placesRepository + getCompositeDisposable().add(placesRepository .save(place) .subscribeOn(Schedulers.io()) .subscribe()); @@ -1531,7 +1531,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment @Override public void stopQuery() { stopQuery = true; - compositeDisposable.clear(); + getCompositeDisposable().clear(); } /** diff --git a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt index a27993f9e..443a112dd 100644 --- a/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt +++ b/app/src/main/java/fr/free/nrw/commons/repository/UploadRepository.kt @@ -204,7 +204,7 @@ class UploadRepository @Inject constructor( * @param filePath file to be checked * @return IMAGE_DUPLICATE or IMAGE_OK */ - fun checkDuplicateImage(originalFilePath: Uri, modifiedFilePath: Uri): Single { + fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single { return uploadModel.checkDuplicateImage(originalFilePath, modifiedFilePath) } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt index 0b2c47ddc..5eeed2bc6 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/PendingUploadsPresenter.kt @@ -28,8 +28,7 @@ import javax.inject.Named /** * The presenter class for PendingUploadsFragment and FailedUploadsFragment - */ -class PendingUploadsPresenter @Inject internal constructor( + */ class PendingUploadsPresenter @Inject internal constructor( private val contributionBoundaryCallback: ContributionBoundaryCallback, private val contributionsRemoteDataSource: ContributionsRemoteDataSource, private val contributionsRepository: ContributionsRepository, @@ -89,12 +88,16 @@ class PendingUploadsPresenter @Inject internal constructor( * @param context The context in which the operation is being performed. */ override fun deleteUpload(contribution: Contribution?, context: Context?) { - compositeDisposable.add( + contribution?.let { contributionsRepository - .deleteContributionFromDB(contribution) + .deleteContributionFromDB(it) .subscribeOn(ioThreadScheduler) .subscribe() - ) + }?.let { + compositeDisposable.add( + it + ) + } } /** diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt index 56ad9dd84..020284934 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadActivity.kt @@ -679,7 +679,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C } private fun receiveExternalSharedItems() { - uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent) + uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent).toMutableList() } private fun receiveInternalSharedItems() { diff --git a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt index 80037a028..7a92cf6c5 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/categories/UploadCategoriesFragment.kt @@ -1,5 +1,6 @@ package fr.free.nrw.commons.upload.categories +import android.annotation.SuppressLint import android.app.Activity import android.app.ProgressDialog import android.content.Context @@ -89,6 +90,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View { } } + @SuppressLint("StringFormatMatches") private fun init() { if (binding == null) { return @@ -372,8 +374,9 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View { (requireActivity() as AppCompatActivity).supportActionBar?.hide() + if (parentFragment?.parentFragment?.parentFragment is ContributionsFragment) { - ((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = View.GONE + ((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding?.cardViewNearby?.visibility = View.GONE } } } diff --git a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt index 782b15c0c..75db6ffc0 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/worker/UploadWorker.kt @@ -149,7 +149,7 @@ class UploadWorker( currentNotification.build(), ) contribution!!.transferred = transferred - contributionDao.update(contribution).blockingAwait() + contributionDao.update(contribution!!).blockingAwait() } open fun onChunkUploaded( diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt index 397e03070..0171f2693 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionViewHolderUnitTests.kt @@ -115,11 +115,10 @@ class ContributionViewHolderUnitTests { @Throws(Exception::class) fun testDisplayWikipediaButton() { Shadows.shadowOf(Looper.getMainLooper()).idle() - val method: Method = - ContributionViewHolder::class.java.getDeclaredMethod( - "displayWikipediaButton", - Boolean::class.javaObjectType, - ) + val method: Method = ContributionViewHolder::class.java.getDeclaredMethod( + "displayWikipediaButton", + Boolean::class.javaPrimitiveType + ) method.isAccessible = true method.invoke(contributionViewHolder, false) } diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt index db2475f63..54228bc13 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsListFragmentUnitTests.kt @@ -89,7 +89,7 @@ class ContributionsListFragmentUnitTests { Shadows.shadowOf(Looper.getMainLooper()).idle() fragment.rvContributionsList = mock() fragment.scrollToTop() - verify(fragment.rvContributionsList).smoothScrollToPosition(0) + verify(fragment.rvContributionsList)?.smoothScrollToPosition(0) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt index 780322603..b3750c5f3 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/MainActivityUnitTests.kt @@ -448,7 +448,7 @@ class MainActivityUnitTests { fun testOnSetUpPagerNearBy() { val item = Mockito.mock(MenuItem::class.java) `when`(item.title).thenReturn(activity.getString(R.string.nearby_fragment)) - activity.navListener.onNavigationItemSelected(item) + activity.navListener?.onNavigationItemSelected(item) verify(item, Mockito.times(3)).title verify(applicationKvStore, Mockito.times(1)) .putBoolean("last_opened_nearby", true) @@ -459,7 +459,7 @@ class MainActivityUnitTests { fun testOnSetUpPagerOtherThanNearBy() { val item = Mockito.mock(MenuItem::class.java) `when`(item.title).thenReturn(activity.getString(R.string.bookmarks)) - activity.navListener.onNavigationItemSelected(item) + activity.navListener?.onNavigationItemSelected(item) verify(item, Mockito.times(3)).title verify(applicationKvStore, Mockito.times(1)) .putBoolean("last_opened_nearby", false) From e9e26973693227d64e926ccb4263846a5d6ef22b Mon Sep 17 00:00:00 2001 From: Sonal Yadav Date: Fri, 7 Feb 2025 19:40:18 +0530 Subject: [PATCH 15/22] Fix: Fix crash when adding location after removing a picture (#6175) * Fix: Resolved the Crash in UploadMediaDetailFragment * Fix uninitialized basicKvStoreFactory in UploadMediaPresenter --------- Co-authored-by: Nicolas Raoul --- .../mediaDetails/UploadMediaDetailFragment.kt | 8 ++++++-- .../upload/mediaDetails/UploadMediaPresenter.kt | 14 +++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt index 92a46b92a..af850a7e3 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaDetailFragment.kt @@ -15,6 +15,7 @@ import android.widget.CompoundButton import android.widget.ImageView import android.widget.Toast import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.os.bundleOf @@ -60,8 +61,7 @@ import javax.inject.Named class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener { - private val startForResult = registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), ::onCameraPosition) + private lateinit var startForResult: ActivityResultLauncher private val startForEditActivityResult = registerForActivityResult( ActivityResultContracts.StartActivityForResult(), ::onEditActivityResult) @@ -135,6 +135,10 @@ class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContra if (savedInstanceState != null && uploadableFile == null) { uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE) } + // Register the ActivityResultLauncher for LocationPickerActivity + startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + onCameraPosition(result) + } } fun setImageToBeUploaded( diff --git a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt index 55cead370..90c426091 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt +++ b/app/src/main/java/fr/free/nrw/commons/upload/mediaDetails/UploadMediaPresenter.kt @@ -51,7 +51,7 @@ class UploadMediaPresenter @Inject constructor( } } } - lateinit var basicKvStoreFactory: (String) -> BasicKvStore + private var basicKvStoreFactory: ((String) -> BasicKvStore)? = null override fun onAttachView(view: UploadMediaDetailsContract.View) { this.view = view @@ -339,8 +339,8 @@ class UploadMediaPresenter @Inject constructor( */ override fun checkImageQuality(uploadItem: UploadItem, index: Int) { if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) { - val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize) - .getString(UPLOAD_QUALITIES_KEY, null) + val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + ?.getString(UPLOAD_QUALITIES_KEY, null) try { val imageQuality = value.asJsonObject()["UploadItem$index"] as Int view.showProgress(false) @@ -363,8 +363,8 @@ class UploadMediaPresenter @Inject constructor( * @param index Index of the UploadItem which was deleted */ override fun updateImageQualitiesJSON(size: Int, index: Int) { - val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize) - .getString(UPLOAD_QUALITIES_KEY, null) + val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + ?.getString(UPLOAD_QUALITIES_KEY, null) try { val jsonObject = value.asJsonObject().apply { for (i in index until (size - 1)) { @@ -372,8 +372,8 @@ class UploadMediaPresenter @Inject constructor( } remove("UploadItem" + (size - 1)) } - basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize) - .putString(UPLOAD_QUALITIES_KEY, jsonObject.toString()) + basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) } + ?.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString()) } catch (e: Exception) { Timber.e(e) } From a529ba80327806ff207565f066accdc480950fcb Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Mon, 10 Feb 2025 13:01:39 +0100 Subject: [PATCH 16/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-ast/strings.xml | 2 +- app/src/main/res/values-hi/strings.xml | 31 ++++++++++++++++------ app/src/main/res/values-zh-rTW/strings.xml | 11 ++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index 9b833119c..8d0dba79a 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -145,7 +145,7 @@ Inda nun xubió denguna foto. Reintentar Zarrar - El presentar esta imaxe, declaro que ye una obra propia, que nun contien material con derechu d\'autor o «selfies», y que s\'atien a les <a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\">polítiques de Wikimedia Commons</a>. + El presentar esta imaxe, declaro que ye una obra propia, que nun contién material con derechu d\'autor o «selfies», y que s\'atien a les <a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\">polítiques de Wikimedia Commons</a>. Descargar Llicencia predeterminada Usar un títulu y descripción anterior diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 152edac1c..4ff40afbf 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -6,6 +6,7 @@ * Anandra * AnupamM * Bhatakati aatma +* Bunnypranav * Gopalindians * Nilesh shukla * Nitin1485 @@ -28,33 +29,47 @@ कॉमन्स गिटहब सोर्स कोड कॉमन्स का प्रतीक चिन्ह कॉमन्स का जालस्थान + निकास स्थान चयनकर्ता जमा करें + एक और विवरण जोड़ें + नया योगदान + कैमरे से योगदान जोड़ें + फ़ोटो से योगदान जोड़ें + पिछले योगदान गैलरी से योगदान जोड़ें + कैप्शन + भाषा विवरण कैप्शन विवरण चित्र सभी + ऊपर टॉगल करें + स्थान राज्य आज का चित्र %1$d फ़ाइल अपलोड हो रही %1$d फ़ाइलें अपलोड हो रहीं - - \@string/contributions_subtitle_zero + (%1$d) (%1$d) - - %1$d अपलोड शुरू - %1$d अपलोड शुरू + अपलोड शुरू + + %d अपलोड संसाधित + %d अपलोड संसाधित - - %1$d अपलोड - %1$d अपलोड + + %d अपलोड + %d अपलोड इस चित्र का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा इन चित्रों का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा + + %1$d अपलोड + %1$d अपलोड + खोजें स्वरूप सामान्य diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 25d401bcd..ca11a4ec9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,5 +1,6 @@ diff --git a/app/src/main/res/values-ce/strings.xml b/app/src/main/res/values-ce/strings.xml index 395bfbca3..f12c235ff 100644 --- a/app/src/main/res/values-ce/strings.xml +++ b/app/src/main/res/values-ce/strings.xml @@ -71,7 +71,7 @@ ЧугӀо Йицйелла пароль? ДӀайаздалар - Системин чудахар + Системин чу дахар Дехар до, собарде… титраш ​​а, йийцарш а карладохуш ду.. Дехар до, собарде… diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml index 2dc2366a8..0fc6a3dc0 100644 --- a/app/src/main/res/values-ps/strings.xml +++ b/app/src/main/res/values-ps/strings.xml @@ -6,24 +6,72 @@ * Baloch Khan * Ibrahim khashrowdi * Saraiki +* شاه زمان پټان --> - - 1 پورته کول - %1$d پورته کول + خونديځ ګيټهوب سرچينه کوډ + خونديځ نښان + خونديځ وېبپاڼه + له ځای ټاکونکي وتل + سپارل + بل سپيناوی ورزياتول + نوې ونډې ورزياتول + د کامرې له لارې ونډه ورزياتول + انځورونو له لارې ونډه ورزياتول + د پخوانيو ونډو له انځورتونه د ونډې ورزياتول + نيونګې + ژبې سپيناوی + نيونګ + سپيناوی + انځور + ټول + پورته کول + لټون ليد + ځای حالت + ورځې انځور + + %1$d دوتنه پورته کول + %1$d دوتنې پورته کول + + + (%1$d) + (%1$d) + + پورته کولو پيل + + جريان %d پورته کول + پورته کولو %d جريان + + + %d upload + %d پورته کول دا انځور به د %1$s په منښتليک سمبال وي. + سپړنه + ښکارېدنه + ټولګړی + غبرګون + پټنتيا ويکي خونديځ امستنې + خونديځ ته راپورته کول + راپورته کول جريان لري کارن‌نوم پټنوم + خپل خونديځ بېټا ګڼون ته ورننوځئ ننوتل + پټنوم مو هېر شوی؟ نومليکنه په ننوتلو کې دی لطفاً تم شۍ … - غونډال کې بريالی ورننوتلۍ! - غونډال کې ننوتنه نابريالې شوه! + نيونګې او سپيناوي تازه کول + په تمه اوسئ + بريالی ننوتون + ناسم ننوتون دوتنه و نه موندل شوه. لطفاً د يوې بلې دوتنې د موندلو هڅه وکړئ. + د بياځلي هڅې وروستۍ اندازه پوره شوه! مهرباني وکړئ، لغوه يې کړئ او بيا د راپورته کولو هڅه وکړئ + بيټري سمون بندول؟ + کله چې د بیټرۍ اصلاح بنده وي، له ۳ څخه زیاتو عکسونو اپلوډ کول ډیر باوري کار کوي. مهرباني وکړئ د اسانه اپلوډ تجربې لپاره د کامنز ایپ لپاره د ترتیباتو څخه د بیټرۍ اصلاح بند کړئ. \n\n د بیټرۍ اصلاح بندولو لپاره ممکنه ګامونه:\n\n لومړی ګام: لاندې \'ترتیبات\' تڼۍ باندې کلیک وکړئ.\n\n دوهم ګام: له \'نه غوره شوی\' څخه \'ټول ایپس\' ته واړوئ.\n\n دریم ګام: د \"کامن\" یا \"fr.free.nrw.commons\" لټون وکړئ.\n\n څلورم ګام: دا کلیک کړئ او \'غوره نه کړئ\' غوره کړئ.\n\n پنځم ګام: \'بشپړ شوی\' فشار ورکړئ. پورته کېدنه پيل شوه! %1$s پورته شوی! د %1$s پورته کول From 7a685b1241f14bd56e83efffbfcecae6ccc11a50 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Mon, 17 Feb 2025 13:01:49 +0100 Subject: [PATCH 19/22] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-iw/strings.xml | 9 ++++++--- app/src/main/res/values-qq/strings.xml | 2 ++ app/src/main/res/values-zh/strings.xml | 3 +++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 7f2d9a8f1..010e440b1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,5 +1,6 @@