diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 958c13fda..bcbef52fd 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,6 +1,11 @@ name: Android CI -on: [push, pull_request, workflow_dispatch] +on: [push, pull_request, workflow_dispatch] + +permissions: + pull-requests: write + contents: read + actions: read concurrency: group: build-${{ github.event.pull_request.number || github.ref }} @@ -102,3 +107,64 @@ jobs: with: name: prodDebugAPK path: app/build/outputs/apk/prod/debug/app-*.apk + + - name: Comment on PR with APK download links + if: github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + try { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN is not set. Please check workflow permissions.'); + } + + + const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId + }); + + if (!artifacts || artifacts.length === 0) { + console.log('No artifacts found for this workflow run.'); + return; + } + + const betaArtifact = artifacts.find(artifact => artifact.name === "betaDebugAPK"); + const prodArtifact = artifacts.find(artifact => artifact.name === "prodDebugAPK"); + + if (!betaArtifact || !prodArtifact) { + console.log('Could not find both Beta and Prod APK artifacts.'); + console.log('Available artifacts:', artifacts.map(a => a.name).join(', ')); + return; + } + + const betaDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${context.runId}/artifacts/${betaArtifact.id}`; + const prodDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${context.runId}/artifacts/${prodArtifact.id}`; + + const commentBody = ` + 📱 **APK for pull request is ready to see the changes** 📱 + - [Download Beta APK](${betaDownloadUrl}) + - [Download Prod APK](${prodDownloadUrl}) + `; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody + }); + + console.log('Successfully posted comment with APK download links'); + } catch (error) { + console.error('Error in PR comment creation:', error); + if (error.message.includes('GITHUB_TOKEN')) { + core.setFailed('Missing or invalid GITHUB_TOKEN. Please check repository secrets configuration.'); + } else if (error.status === 403) { + core.setFailed('Permission denied. Please check workflow permissions in repository settings.'); + } else { + core.setFailed(`Workflow failed: ${error.message}`); + } + } 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 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/.attach_pid781771 b/app/.attach_pid781771 new file mode 100644 index 000000000..e69de29bb 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 + ) + } +} 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/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 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/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 219a55d48..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java +++ /dev/null @@ -1,132 +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.lifecycle.MutableLiveData; -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.auth.SessionManager; -import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener; -import io.reactivex.Scheduler; -import io.reactivex.disposables.CompositeDisposable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import kotlin.Unit; -import timber.log.Timber; - -/** - * 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; - @Inject - ContributionsRemoteDataSource contributionsRemoteDataSource; - - @Inject - SessionManager sessionManager; - - LiveData> contributionList; - - private MutableLiveData> liveData = new MutableLiveData<>(); - - private List existingContributions = new ArrayList<>(); - - @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(); - contributionList.observeForever(pagedList -> { - if (pagedList != null) { - existingContributions.clear(); - existingContributions.addAll(pagedList); - liveData.setValue(existingContributions); // Update liveData with the latest list - } - }); - } - - @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; - }); - } - -} \ No newline at end of file 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 495a4bc64..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsPresenter.java +++ /dev/null @@ -1,94 +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.getLocalUriPath().getPath()) - .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 534010db1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java +++ /dev/null @@ -1,567 +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.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.ImageView; -import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; -import androidx.work.ExistingWorkPolicy; -import com.google.android.material.bottomnavigation.BottomNavigationView.OnNavigationItemSelectedListener; -import fr.free.nrw.commons.databinding.FragmentContributionsListBinding; -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.MediaClient; -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.profile.ProfileActivity; -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.Scheduler; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import javax.inject.Inject; -import javax.inject.Named; -import kotlin.Unit; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -public class MainActivity extends BaseActivity - implements OnBackStackChangedListener { - - @Inject - SessionManager sessionManager; - @Inject - ContributionController controller; - @Inject - ContributionDao contributionDao; - - @Inject - ContributionsListPresenter contributionsListPresenter; - @Inject - ContributionsRemoteDataSource dataSource; - - private ContributionsFragment contributionsFragment; - private NearbyParentFragment nearbyParentFragment; - private ExploreFragment exploreFragment; - private BookmarkFragment bookmarkFragment; - public ActiveFragment activeFragment; - private MediaDetailPagerFragment mediaDetailPagerFragment; - private 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; - - private FragmentContributionsListBinding refresh; - - private String userName; - - /** - * 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); - } - } - refresh = FragmentContributionsListBinding.inflate(getLayoutInflater()); - if (getIntent().getExtras() != null) { - userName = getIntent().getExtras().getString(ProfileActivity.KEY_USERNAME); - } - - if (StringUtils.isEmpty(userName)) { - userName = sessionManager.getUserName(); - } - 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. - 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; - } - - 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; - case R.id.menu_refresh: - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.contribution_activity_notification_menu, menu); - - MenuItem refreshItem = menu.findItem(R.id.menu_refresh); - if (refreshItem != null) { - View actionView = refreshItem.getActionView(); - if (actionView != null) { - ImageView refreshIcon = actionView.findViewById(R.id.refresh_icon); - if (refreshIcon != null) { - refreshIcon.setOnClickListener(v -> { - v.clearAnimation(); - Animation rotateAnimation = AnimationUtils.loadAnimation(this, R.anim.rotate); - v.startAnimation(rotateAnimation); - - // Initialize userName if it's null - if (userName == null) { - userName = sessionManager.getUserName(); - } - - if (Objects.equals(sessionManager.getUserName(), userName)) { - if (refresh != null && refresh.swipeRefreshLayout != null) { - refresh.swipeRefreshLayout.setRefreshing(true); - refresh.swipeRefreshLayout.setEnabled(true); - contributionsListPresenter.refreshList(refresh.swipeRefreshLayout); - } - } else { - if (refresh != null && refresh.swipeRefreshLayout != null) { - refresh.swipeRefreshLayout.setEnabled(false); - } - } - }); - } - } - } - - return true; - } - - public void centerMapToPlace(Place place) { - setSelectedItemId(NavTab.NEARBY.code()); - nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback( - new NearbyParentFragmentInstanceReadyCallback() { - @Override - public void onReady() { - nearbyParentFragment.centerMapToPlace(place); - } - }); - } - - @Override - protected void onResume() { - super.onResume(); - if (applicationKvStore.getBoolean("firstrun", true) && - !applicationKvStore.getBoolean("login_skipped")) { - defaultKvStore.putBoolean("inAppCameraFirstRun", true); - WelcomeActivity.startYourself(this); - } - - retryAllFailedUploads(); - //check for new contributions - // Initialize userName if it's null - if (userName == null) { - userName = sessionManager.getUserName(); - } - - if (Objects.equals(sessionManager.getUserName(), userName)) { - if (refresh != null && refresh.swipeRefreshLayout != null) { - refresh.swipeRefreshLayout.setRefreshing(true); - refresh.swipeRefreshLayout.setEnabled(true); - contributionsListPresenter.refreshList(refresh.swipeRefreshLayout); - } - } else { - if (refresh != null && refresh.swipeRefreshLayout != null) { - refresh.swipeRefreshLayout.setEnabled(false); - } - } - } - - @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 OnNavigationItemSelectedListener getNavListener() { - return navListener; - } -} \ No newline at end of file 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..a61567393 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -0,0 +1,569 @@ +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.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback +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( + object : NearbyParentFragmentInstanceReadyCallback { + override fun 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 + */ + 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/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 fd1ea1f28..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 @@ -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,23 +459,25 @@ 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 { nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(getLastMapFocus(), currentLatLng, false); } - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .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) { @@ -545,10 +603,12 @@ 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.setVisibility( + place.hasCommonsLink() ? View.VISIBLE : View.GONE); binding.bottomSheetDetailsBinding.commonsButton.setOnClickListener( view -> Utils.handleWebUrl(getContext(), place.siteLinks.getCommonsLink())); @@ -562,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() @@ -640,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 + } } /** @@ -707,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); + } } /** @@ -825,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( @@ -850,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; } @@ -910,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/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/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/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index 4c993fb80..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 @@ -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. 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/Place.java b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java index 21dd14131..3b3b798eb 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java @@ -232,13 +232,23 @@ public class Place implements Parcelable { */ @Nullable public String getWikiDataEntityId() { + if (this.entityID != null && !this.entityID.equals("")) { + return this.entityID; + } + if (!hasWikidataLink()) { Timber.d("Wikidata entity ID is null for place with sitelink %s", siteLinks.toString()); return null; } + //Determine entityID from link String wikiDataLink = siteLinks.getWikidataLink().toString(); - return wikiDataLink.replace("http://www.wikidata.org/entity/", ""); + + if (wikiDataLink.contains("http://www.wikidata.org/entity/")) { + this.entityID = wikiDataLink.substring("http://www.wikidata.org/entity/".length()); + return this.entityID; + } + return null; } /** 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 deleted file mode 100644 index f3224de7f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ /dev/null @@ -1,2397 +0,0 @@ -package fr.free.nrw.commons.nearby.fragments; - -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.CUSTOM_QUERY; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SLIGHTLY_CHANGED; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.MAP_UPDATED; -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - -import android.Manifest.permission; -import android.annotation.SuppressLint; -import android.app.ProgressDialog; -import android.content.ActivityNotFoundException; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.graphics.drawable.Drawable; -import android.location.Location; -import android.location.LocationManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.Toast; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog.Builder; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.lifecycle.LifecycleCoroutineScope; -import androidx.lifecycle.LifecycleOwnerKt; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback; -import com.google.android.material.snackbar.Snackbar; -import com.jakewharton.rxbinding2.view.RxView; -import com.jakewharton.rxbinding3.appcompat.RxSearchView; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.CommonsApplication.BaseLogoutListener; -import fr.free.nrw.commons.MapController.NearbyPlacesInfo; -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.ContributionController; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment; -import fr.free.nrw.commons.databinding.FragmentNearbyParentBinding; -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.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType; -import fr.free.nrw.commons.location.LocationUpdateListener; -import fr.free.nrw.commons.nearby.BottomSheetAdapter; -import fr.free.nrw.commons.nearby.CheckBoxTriStates; -import fr.free.nrw.commons.nearby.Label; -import fr.free.nrw.commons.nearby.MarkerPlaceGroup; -import fr.free.nrw.commons.nearby.NearbyController; -import fr.free.nrw.commons.nearby.NearbyFilterSearchRecyclerViewAdapter; -import fr.free.nrw.commons.nearby.NearbyFilterState; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.PlacesRepository; -import fr.free.nrw.commons.nearby.Sitelinks; -import fr.free.nrw.commons.nearby.WikidataFeedback; -import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract; -import fr.free.nrw.commons.nearby.fragments.AdvanceQueryFragment.Callback; -import fr.free.nrw.commons.nearby.model.BottomSheetItem; -import fr.free.nrw.commons.nearby.presenter.NearbyParentFragmentPresenter; -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.ExecutorUtils; -import fr.free.nrw.commons.utils.LayoutUtils; -import fr.free.nrw.commons.utils.MapUtils; -import fr.free.nrw.commons.utils.NearbyFABUtils; -import fr.free.nrw.commons.utils.NetworkUtils; -import fr.free.nrw.commons.utils.SystemThemeUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.wikidata.WikidataEditListener; -import io.reactivex.Completable; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import javax.inject.Inject; -import javax.inject.Named; -import kotlin.Unit; -import org.jetbrains.annotations.NotNull; -import org.osmdroid.api.IGeoPoint; -import org.osmdroid.events.MapEventsReceiver; -import org.osmdroid.events.MapListener; -import org.osmdroid.events.ScrollEvent; -import org.osmdroid.events.ZoomEvent; -import org.osmdroid.tileprovider.tilesource.TileSourceFactory; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.util.constants.GeoConstants.UnitOfMeasure; -import org.osmdroid.views.CustomZoomButtonsController.Visibility; -import org.osmdroid.views.overlay.MapEventsOverlay; -import org.osmdroid.views.overlay.Marker; -import org.osmdroid.views.overlay.Overlay; -import org.osmdroid.views.overlay.ScaleBarOverlay; -import org.osmdroid.views.overlay.ScaleDiskOverlay; -import org.osmdroid.views.overlay.TilesOverlay; -import timber.log.Timber; - - -public class NearbyParentFragment extends CommonsDaggerSupportFragment - implements NearbyParentFragmentContract.View, - WikidataEditListener.WikidataP18EditListener, LocationUpdateListener, - LocationPermissionCallback, BottomSheetAdapter.ItemClickListener { - - FragmentNearbyParentBinding binding; - - public final MapEventsOverlay mapEventsOverlay = new MapEventsOverlay(new MapEventsReceiver() { - @Override - public boolean singleTapConfirmedHelper(GeoPoint p) { - if (clickedMarker != null) { - clickedMarker.closeInfoWindow(); - } else { - Timber.e("CLICKED MARKER IS NULL"); - } - if (isListBottomSheetExpanded()) { - // Back should first hide the bottom sheet if it is expanded - hideBottomSheet(); - } else if (isDetailsBottomSheetVisible()) { - hideBottomDetailsSheet(); - } - return true; - } - - @Override - public boolean longPressHelper(GeoPoint p) { - return false; - } - }); - - @Inject - LocationServiceManager locationManager; - @Inject - NearbyController nearbyController; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - @Inject - BookmarkLocationsDao bookmarkLocationDao; - @Inject - PlacesRepository placesRepository; - @Inject - ContributionController controller; - @Inject - WikidataEditListener wikidataEditListener; - @Inject - SystemThemeUtils systemThemeUtils; - @Inject - CommonPlaceClickActions commonPlaceClickActions; - - private LocationPermissionsHelper locationPermissionsHelper; - private NearbyFilterSearchRecyclerViewAdapter nearbyFilterSearchRecyclerViewAdapter; - private BottomSheetBehavior bottomSheetListBehavior; - private BottomSheetBehavior bottomSheetDetailsBehavior; - private Animation rotate_backward; - private Animation fab_close; - private Animation fab_open; - private Animation rotate_forward; - private static final float ZOOM_LEVEL = 15f; - private final String NETWORK_INTENT_ACTION = "android.net.conn.CONNECTIVITY_CHANGE"; - private BroadcastReceiver broadcastReceiver; - private boolean isNetworkErrorOccurred; - private Snackbar snackbar; - private View view; - private LifecycleCoroutineScope scope; - private NearbyParentFragmentPresenter presenter; - private boolean isDarkTheme; - private boolean isFABsExpanded; - private Place selectedPlace; - private Marker clickedMarker; - private ProgressDialog progressDialog; - private final double CAMERA_TARGET_SHIFT_FACTOR_PORTRAIT = 0.005; - private final double CAMERA_TARGET_SHIFT_FACTOR_LANDSCAPE = 0.004; - private boolean isPermissionDenied; - private boolean recenterToUserLocation; - private GeoPoint mapCenter; - IntentFilter intentFilter = new IntentFilter(NETWORK_INTENT_ACTION); - private Place lastPlaceToCenter; - private LatLng lastKnownLocation; - private boolean isVisibleToUser; - private LatLng lastFocusLocation; - private PlaceAdapter adapter; - private GeoPoint lastMapFocus; - private NearbyParentFragmentInstanceReadyCallback nearbyParentFragmentInstanceReadyCallback; - private boolean isAdvancedQueryFragmentVisible = false; - private Place nearestPlace; - private volatile boolean stopQuery; - - private final Handler searchHandler = new Handler(); - private Runnable searchRunnable; - - private LatLng updatedLatLng; - private boolean searchable; - - private ConstraintLayout nearbyLegend; - - private GridLayoutManager gridLayoutManager; - private List dataList; - private BottomSheetAdapter bottomSheetAdapter; - - 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)); - } - } - } - }); - - private ActivityResultLauncher locationPermissionLauncher = registerForActivityResult( - new RequestPermission(), isGranted -> { - if (isGranted) { - locationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - DialogUtil.showAlertDialog(getActivity(), - getActivity().getString(R.string.location_permission_title), - getActivity().getString(R.string.location_permission_rationale_nearby), - getActivity().getString(android.R.string.ok), - getActivity().getString(android.R.string.cancel), - () -> { - askForLocationPermission(); - }, - null, - null - ); - } else { - if (isPermissionDenied) { - locationPermissionsHelper.showAppSettingsDialog(getActivity(), - R.string.nearby_needs_location); - } - Timber.d("The user checked 'Don't ask again' or denied the permission twice"); - isPermissionDenied = true; - } - } - }); - - /** - * WLM URL - */ - public static final String WLM_URL = "https://commons.wikimedia.org/wiki/Commons:Mobile_app/Contributing_to_WLM_using_the_app"; - - @NonNull - public static NearbyParentFragment newInstance() { - NearbyParentFragment fragment = new NearbyParentFragment(); - fragment.setRetainInstance(true); - return fragment; - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentNearbyParentBinding.inflate(inflater, container, false); - view = binding.getRoot(); - - initNetworkBroadCastReceiver(); - scope = LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner()); - presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, nearbyController); - progressDialog = new ProgressDialog(getActivity()); - progressDialog.setCancelable(false); - progressDialog.setMessage("Saving in progress..."); - setHasOptionsMenu(true); - - // Inflate the layout for this fragment - return view; - - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - 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 saveAsGPXButton = menu.findItem(R.id.list_item_gpx); - MenuItem saveAsKMLButton = menu.findItem(R.id.list_item_kml); - refreshButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - try { - emptyCache(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - listMenu.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - listOptionMenuItemClicked(); - return false; - } - }); - saveAsGPXButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - - @Override - public boolean onMenuItemClick(@NonNull MenuItem item) { - try { - progressDialog.setTitle(getString(R.string.saving_gpx_file)); - progressDialog.show(); - savePlacesAsGPX(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - saveAsKMLButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - - @Override - public boolean onMenuItemClick(@NonNull MenuItem item) { - try { - progressDialog.setTitle(getString(R.string.saving_kml_file)); - progressDialog.show(); - savePlacesAsKML(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - isDarkTheme = systemThemeUtils.isDeviceInNightMode(); - if (Utils.isMonumentsEnabled(new Date())) { - binding.rlContainerWlmMonthMessage.setVisibility(View.VISIBLE); - } else { - binding.rlContainerWlmMonthMessage.setVisibility(View.GONE); - } - locationPermissionsHelper = new LocationPermissionsHelper(getActivity(), locationManager, - this); - - // Set up the floating activity button to toggle the visibility of the legend - binding.fabLegend.setOnClickListener(v -> { - if (binding.nearbyLegendLayout.getRoot().getVisibility() == View.VISIBLE) { - binding.nearbyLegendLayout.getRoot().setVisibility(View.GONE); - } else { - binding.nearbyLegendLayout.getRoot().setVisibility(View.VISIBLE); - } - }); - - presenter.attachView(this); - isPermissionDenied = false; - recenterToUserLocation = false; - initThemePreferences(); - initViews(); - presenter.setActionListeners(applicationKvStore); - org.osmdroid.config.Configuration.getInstance().load(this.getContext(), - PreferenceManager.getDefaultSharedPreferences(this.getContext())); - - // Use the Wikimedia tile server, rather than OpenStreetMap (Mapnik) which has various - // restrictions that we do not satisfy. - binding.map.setTileSource(TileSourceFactory.WIKIMEDIA); - binding.map.setTilesScaledToDpi(true); - - // Add referer HTTP header because the Wikimedia tile server requires it. - // This was suggested by Dmitry Brant within an email thread between us and WMF. - org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put( - "Referer", "http://maps.wikimedia.org/" - ); - - if (applicationKvStore.getString("LastLocation") - != null) { // Checking for last searched location - String[] locationLatLng = applicationKvStore.getString("LastLocation").split(","); - lastMapFocus = new GeoPoint(Double.valueOf(locationLatLng[0]), - Double.valueOf(locationLatLng[1])); - } else { - lastMapFocus = new GeoPoint(51.50550, -0.07520); - } - ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.map); - scaleBarOverlay.setScaleBarOffset(15, 25); - Paint barPaint = new Paint(); - barPaint.setARGB(200, 255, 250, 250); - scaleBarOverlay.setBackgroundPaint(barPaint); - scaleBarOverlay.enableScaleBar(); - binding.map.getOverlays().add(scaleBarOverlay); - binding.map.getZoomController().setVisibility(Visibility.NEVER); - binding.map.getController().setZoom(ZOOM_LEVEL); - binding.map.getOverlays().add(mapEventsOverlay); - - binding.map.addMapListener(new MapListener() { - @Override - public boolean onScroll(ScrollEvent event) { - presenter.handleMapScrolled(scope, !isNetworkErrorOccurred); - return true; - } - - @Override - public boolean onZoom(ZoomEvent event) { - return false; - } - - }); - - binding.map.setMultiTouchControls(true); - if (nearbyParentFragmentInstanceReadyCallback != null) { - nearbyParentFragmentInstanceReadyCallback.onReady(); - } - initNearbyFilter(); - addCheckBoxCallback(); - 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)); - } else { - //noinspection deprecation - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); - } - binding.tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); - binding.nearbyFilterList.btnAdvancedOptions.setOnClickListener(v -> { - binding.nearbyFilter.searchViewLayout.searchView.clearFocus(); - showHideAdvancedQueryFragment(true); - final AdvanceQueryFragment fragment = new AdvanceQueryFragment(); - final Bundle bundle = new Bundle(); - try { - bundle.putString("query", - FileUtils.INSTANCE.readFromResource( - "/queries/radius_query_for_upload_wizard.rq") - ); - } catch (IOException e) { - Timber.e(e); - } - fragment.setArguments(bundle); - fragment.callback = new Callback() { - @Override - public void close() { - showHideAdvancedQueryFragment(false); - } - - @Override - public void reset() { - presenter.setAdvancedQuery(null); - presenter.updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED); - showHideAdvancedQueryFragment(false); - } - - @Override - public void apply(@NotNull final String query) { - presenter.setAdvancedQuery(query); - presenter.updateMapAndList(CUSTOM_QUERY); - showHideAdvancedQueryFragment(false); - } - }; - getChildFragmentManager().beginTransaction() - .replace(R.id.fl_container_nearby_children, fragment) - .commit(); - }); - - binding.tvLearnMore.setOnClickListener(v -> onLearnMoreClicked()); - - if (!locationPermissionsHelper.checkLocationPermission(getActivity())) { - askForLocationPermission(); - } - } - - /** - * Initialise background based on theme, this should be doe ideally via styles, that would need - * another refactor - */ - private void initThemePreferences() { - if (isDarkTheme) { - binding.bottomSheetNearby.rvNearbyList.setBackgroundColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.map.getOverlayManager().getTilesOverlay() - .setColorFilter(TilesOverlay.INVERT_COLORS); - } else { - binding.bottomSheetNearby.rvNearbyList.setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - } - } - - private void initRvNearbyList() { - binding.bottomSheetNearby.rvNearbyList.setLayoutManager( - new LinearLayoutManager(getContext())); - adapter = new PlaceAdapter(bookmarkLocationDao, - place -> { - moveCameraToPosition( - new GeoPoint(place.location.getLatitude(), place.location.getLongitude())); - return Unit.INSTANCE; - }, - (place, isBookmarked) -> { - presenter.toggleBookmarkedStatus(place); - return Unit.INSTANCE; - }, - commonPlaceClickActions, - inAppCameraLocationPermissionLauncher, - galleryPickLauncherForResult, - cameraPickLauncherForResult - ); - binding.bottomSheetNearby.rvNearbyList.setAdapter(adapter); - } - - private void addCheckBoxCallback() { - binding.nearbyFilterList.checkboxTriStates.setCallback( - (o, state, b, b1) -> presenter.filterByMarkerType(o, state, b, b1)); - } - - private void performMapReadyActions() { - if (((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) { - if (applicationKvStore.getBoolean("doNotAskForLocationPermission", false) && - !locationPermissionsHelper.checkLocationPermission(getActivity())) { - isPermissionDenied = true; - } - } - presenter.onMapReady(); - } - - @Override - public void askForLocationPermission() { - Timber.d("Asking for location permission"); - locationPermissionLauncher.launch(permission.ACCESS_FINE_LOCATION); - } - - private void locationPermissionGranted() { - isPermissionDenied = false; - applicationKvStore.putBoolean("doNotAskForLocationPermission", false); - lastKnownLocation = locationManager.getLastLocation(); - LatLng target = lastKnownLocation; - if (lastKnownLocation != null) { - GeoPoint targetP = new GeoPoint(target.getLatitude(), target.getLongitude()); - mapCenter = targetP; - binding.map.getController().setCenter(targetP); - recenterMarkerToPosition(targetP); - moveCameraToPosition(targetP); - } else if (locationManager.isGPSProviderEnabled() - || locationManager.isNetworkProviderEnabled()) { - locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER); - locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); - setProgressBarVisibility(true); - } else { - locationPermissionsHelper.showLocationOffDialog(getActivity(), - R.string.ask_to_turn_location_on_text); - } - presenter.onMapReady(); - registerUnregisterLocationListener(false); - } - - @Override - public void onResume() { - super.onResume(); - binding.map.onResume(); - presenter.attachView(this); - registerNetworkReceiver(); - if (isResumed() && ((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) { - if (locationPermissionsHelper.checkLocationPermission(getActivity())) { - locationPermissionGranted(); - } else { - startMapWithoutPermission(); - } - } - } - - /** - * Starts the map without GPS and without permission By default it points to 51.50550,-0.07520 - * coordinates, other than that it points to the last known location which can be get by the key - * "LastLocation" from applicationKvStore - */ - private void startMapWithoutPermission() { - if (applicationKvStore.getString("LastLocation") != null) { - final String[] locationLatLng - = applicationKvStore.getString("LastLocation").split(","); - lastKnownLocation - = new LatLng(Double.parseDouble(locationLatLng[0]), - Double.parseDouble(locationLatLng[1]), 1f); - } else { - lastKnownLocation = MapUtils.getDefaultLatLng(); - } - if (binding.map != null) { - moveCameraToPosition( - new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); - } - presenter.onMapReady(); - } - - private void registerNetworkReceiver() { - if (getActivity() != null) { - getActivity().registerReceiver(broadcastReceiver, intentFilter); - } - } - - @Override - public void onPause() { - super.onPause(); - binding.map.onPause(); - compositeDisposable.clear(); - presenter.detachView(); - registerUnregisterLocationListener(true); - try { - if (broadcastReceiver != null && getActivity() != null) { - getContext().unregisterReceiver(broadcastReceiver); - } - - if (locationManager != null && presenter != null) { - locationManager.removeLocationListener(presenter); - locationManager.unregisterLocationManager(); - } - } catch (final Exception e) { - Timber.e(e); - //Broadcast receivers should always be unregistered inside catch, you never know if they were already registered or not - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - searchHandler.removeCallbacks(searchRunnable); - presenter.removeNearbyPreferences(applicationKvStore); - } - - private void initViews() { - Timber.d("init views called"); - initBottomSheets(); - loadAnimations(); - setBottomSheetCallbacks(); - addActionToTitle(); - if (!Utils.isMonumentsEnabled(new Date())) { - NearbyFilterState.setWlmSelected(false); - } - } - - /** - * a) Creates bottom sheet behaviours from bottom sheets, sets initial states and visibility b) - * Gets the touch event on the map to perform following actions: if fab is open then close fab. - * if bottom sheet details are expanded then collapse bottom sheet details. if bottom sheet - * details are collapsed then hide the bottom sheet details. if listBottomSheet is open then - * hide the list bottom sheet. - */ - @SuppressLint("ClickableViewAccessibility") - private void initBottomSheets() { - bottomSheetListBehavior = BottomSheetBehavior.from(binding.bottomSheetNearby.bottomSheet); - bottomSheetDetailsBehavior = BottomSheetBehavior.from(binding.bottomSheetDetails.getRoot()); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - binding.bottomSheetDetails.getRoot().setVisibility(View.VISIBLE); - bottomSheetListBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - /** - * 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. - */ - private int getSpanCount() { - int orientation = getResources().getConfiguration().orientation; - if (bottomSheetAdapter != null) { - return (orientation == Configuration.ORIENTATION_PORTRAIT) ? 3 - : bottomSheetAdapter.getItemCount(); - } else { - return (orientation == Configuration.ORIENTATION_PORTRAIT) ? 3 : 6; - } - } - - public void initNearbyFilter() { - binding.nearbyFilterList.getRoot().setVisibility(View.GONE); - hideBottomSheet(); - binding.nearbyFilter.searchViewLayout.searchView.setOnQueryTextFocusChangeListener( - (v, hasFocus) -> { - LayoutUtils.setLayoutHeightAlignedToWidth(1.25, - binding.nearbyFilterList.getRoot()); - if (hasFocus) { - binding.nearbyFilterList.getRoot().setVisibility(View.VISIBLE); - presenter.searchViewGainedFocus(); - } else { - binding.nearbyFilterList.getRoot().setVisibility(View.GONE); - } - }); - binding.nearbyFilterList.searchListView.setHasFixedSize(true); - binding.nearbyFilterList.searchListView.addItemDecoration( - new DividerItemDecoration(getContext(), - DividerItemDecoration.VERTICAL)); - final LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getActivity()); - linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL); - binding.nearbyFilterList.searchListView.setLayoutManager(linearLayoutManager); - nearbyFilterSearchRecyclerViewAdapter = new NearbyFilterSearchRecyclerViewAdapter( - getContext(), new ArrayList<>(Label.valuesAsList()), - binding.nearbyFilterList.searchListView); - nearbyFilterSearchRecyclerViewAdapter.setCallback( - new NearbyFilterSearchRecyclerViewAdapter.Callback() { - @Override - public void setCheckboxUnknown() { - presenter.setCheckboxUnknown(); - } - - @Override - public void filterByMarkerType(final ArrayList

- * Should be called only on creation of Map, there is other method to update markers location - * with users move. - * - * @param currentLatLng current location - */ - @Override - public void addCurrentLocationMarker(final LatLng currentLatLng) { - if (null != currentLatLng && !isPermissionDenied - && locationManager.isGPSProviderEnabled()) { - ExecutorUtils.get().submit(() -> { - Timber.d("Adds current location marker"); - recenterMarkerToPosition( - new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); - }); - } else { - Timber.d("not adding current location marker..current location is null"); - } - } - - @Override - public void filterOutAllMarkers() { - clearAllMarkers(); - } - - /** - * Filters markers based on selectedLabels and chips - * - * @param selectedLabels label list that user clicked - * @param filterForPlaceState true if we filter places for place state - * @param filterForAllNoneType true if we filter places with all none button - */ - @Override - public void filterMarkersByLabels(final List

+ + + + \ 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" /> + + خطأ في جلب المعالم القريبة.
لا توجد عمليات بحث حديثة هل أنت متأكد من أنك تريد مسح سجل بحثك؟ - هل انت متأكد انك تريد الغاء هذا التحميل + هل أنت متأكد أنك تريد إلغاء هذا التحميل؟ هل تريد حذف هذا البحث؟ تم حذف سجل البحث ترشيح للحذف @@ -883,4 +885,6 @@ الشرح تم نسخ التسمية التوضيحية إلى الحافظة مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل. + عرض في استكشاف + عرض في المناطق القريبة 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-az/strings.xml b/app/src/main/res/values-az/strings.xml index cbe23481c..1662c2301 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -10,6 +10,7 @@ * Toghrul Rahimli * Wertuose * Şeyx Şamil +* Əkrəm * Əkrəm Cəfər --> 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-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-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-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-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-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index fd3bbb163..010e440b1 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,5 +1,6 @@ - - 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 پورته کول @@ -88,4 +136,5 @@ امستنې غبرگون وتل + گڼون diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 1be987f42..6b81a5b50 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -1,5 +1,6 @@