Merge remote-tracking branch 'origin/issue#6084' into issue#6084

# Conflicts:
#	app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java
#	app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.java
This commit is contained in:
Sujal-Gupta-SG 2025-02-19 22:10:23 +05:30
commit 6ba3c60efc
96 changed files with 7363 additions and 6365 deletions

View file

@ -2,6 +2,11 @@ name: Android CI
on: [push, pull_request, workflow_dispatch]
permissions:
pull-requests: write
contents: read
actions: read
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@ -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}`);
}
}

41
.github/workflows/build-beta.yml vendored Normal file
View file

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

1
.gitignore vendored
View file

@ -47,3 +47,4 @@ captures/*
# Test and other output
app/jacoco.exec
app/CommonsContributions
app/.*

0
app/.attach_pid781771 Normal file
View file

View file

@ -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<String> sortedLocalizedNamesRef = CommonsApplication.getInstance().getLanguageLookUpTable().getCanonicalNames();
Collections.sort(sortedLocalizedNamesRef);
final ArrayAdapter<String> 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
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,405 +0,0 @@
package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import android.Manifest.permission;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.DataSource.Factory;
import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.filepicker.DefaultCallback;
import fr.free.nrw.commons.filepicker.FilePicker;
import fr.free.nrw.commons.filepicker.FilePicker.ImageSource;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
@Singleton
public class ContributionController {
public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads";
private final JsonKvStore defaultKvStore;
private LatLng locationBeforeImageCapture;
private boolean isInAppCameraUpload;
public LocationPermissionCallback locationPermissionCallback;
private LocationPermissionsHelper locationPermissionsHelper;
// Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// LiveData<PagedList<Contribution>> failedAndPendingContributionList;
LiveData<PagedList<Contribution>> pendingContributionList;
LiveData<PagedList<Contribution>> failedContributionList;
@Inject
LocationServiceManager locationManager;
@Inject
ContributionsRepository repository;
@Inject
public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) {
this.defaultKvStore = defaultKvStore;
}
/**
* Check for permissions and initiate camera click
*/
public void initiateCameraPick(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true);
if (!useExtStorage) {
initiateCameraUpload(activity, resultLauncher);
return;
}
PermissionUtils.checkPermissionsAndPerformAction(activity,
() -> {
if (defaultKvStore.getBoolean("inAppCameraFirstRun")) {
defaultKvStore.putBoolean("inAppCameraFirstRun", false);
askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher);
} else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) {
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher, resultLauncher);
} else {
initiateCameraUpload(activity, resultLauncher);
}
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
PermissionUtils.getPERMISSIONS_STORAGE());
}
/**
* Asks users to provide location access
*
* @param activity
*/
private void createDialogsAndHandleLocationPermissions(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
locationPermissionCallback = new LocationPermissionCallback() {
@Override
public void onLocationPermissionDenied(String toastMessage) {
Toast.makeText(
activity,
toastMessage,
Toast.LENGTH_LONG
).show();
initiateCameraUpload(activity, resultLauncher);
}
@Override
public void onLocationPermissionGranted() {
if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
showLocationOffDialog(activity, R.string.in_app_camera_needs_location,
R.string.in_app_camera_location_unavailable, resultLauncher);
} else {
initiateCameraUpload(activity, resultLauncher);
}
}
};
locationPermissionsHelper = new LocationPermissionsHelper(
activity, locationManager, locationPermissionCallback);
if (inAppCameraLocationPermissionLauncher != null) {
inAppCameraLocationPermissionLauncher.launch(
new String[]{permission.ACCESS_FINE_LOCATION});
}
}
/**
* Shows a dialog alerting the user about location services being off and asking them to turn it
* on
* TODO: Add a seperate callback in LocationPermissionsHelper for this.
* Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
*
* @param activity Activity reference
* @param dialogTextResource Resource id of text to be shown in dialog
* @param toastTextResource Resource id of text to be shown in toast
* @param resultLauncher
*/
private void showLocationOffDialog(Activity activity, int dialogTextResource,
int toastTextResource, ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil
.showAlertDialog(activity,
activity.getString(R.string.ask_to_turn_location_on),
activity.getString(dialogTextResource),
activity.getString(R.string.title_app_shortcut_setting),
activity.getString(R.string.cancel),
() -> locationPermissionsHelper.openLocationSettings(activity),
() -> {
Toast.makeText(activity, activity.getString(toastTextResource),
Toast.LENGTH_LONG).show();
initiateCameraUpload(activity, resultLauncher);
}
);
}
public void handleShowRationaleFlowCameraLocation(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title),
activity.getString(R.string.in_app_camera_location_permission_rationale),
activity.getString(android.R.string.ok),
activity.getString(android.R.string.cancel),
() -> {
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher, resultLauncher);
},
() -> locationPermissionCallback.onLocationPermissionDenied(
activity.getString(R.string.in_app_camera_location_permission_denied)),
null
);
}
/**
* Suggest user to attach location information with pictures. If the user selects "Yes", then:
* <p>
* Location is taken from the EXIF if the default camera application does not redact location
* tags.
* <p>
* Otherwise, if the EXIF metadata does not have location information, then location captured by
* the app is used
*
* @param activity
*/
private void askUserToAllowLocationAccess(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil.showAlertDialog(activity,
activity.getString(R.string.in_app_camera_location_permission_title),
activity.getString(R.string.in_app_camera_location_access_explanation),
activity.getString(R.string.option_allow),
activity.getString(R.string.option_dismiss),
() -> {
defaultKvStore.putBoolean("inAppCameraLocationPref", true);
createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher, resultLauncher);
},
() -> {
ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied);
defaultKvStore.putBoolean("inAppCameraLocationPref", false);
initiateCameraUpload(activity, resultLauncher);
},
null
);
}
/**
* Initiate gallery picker
*/
public void initiateGalleryPick(final Activity activity, ActivityResultLauncher<Intent> resultLauncher, final boolean allowMultipleUploads) {
initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads);
}
/**
* Initiate gallery picker with permission
*/
public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
setPickerConfiguration(activity, true);
PermissionUtils.checkPermissionsAndPerformAction(activity,
() -> FilePicker.openCustomSelector(activity, resultLauncher, 0),
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
PermissionUtils.getPERMISSIONS_STORAGE());
}
/**
* Open chooser for gallery uploads
*/
private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher<Intent> resultLauncher,
final boolean allowMultipleUploads) {
setPickerConfiguration(activity, allowMultipleUploads);
FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred());
}
/**
* Sets configuration for file picker
*/
private void setPickerConfiguration(Activity activity,
boolean allowMultipleUploads) {
boolean copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true);
FilePicker.configuration(activity)
.setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage)
.setAllowMultiplePickInGallery(allowMultipleUploads);
}
/**
* Initiate camera upload by opening camera
*/
private void initiateCameraUpload(Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
setPickerConfiguration(activity, false);
if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) {
locationBeforeImageCapture = locationManager.getLastLocation();
}
isInAppCameraUpload = true;
FilePicker.openCameraForImage(activity, resultLauncher, 0);
}
private boolean isDocumentPhotoPickerPreferred(){
return defaultKvStore.getBoolean(
"openDocumentPhotoPickerPref", true);
}
public void onPictureReturnedFromGallery(ActivityResult result, Activity activity, FilePicker.Callbacks callbacks){
if(isDocumentPhotoPickerPreferred()){
FilePicker.onPictureReturnedFromDocuments(result, activity, callbacks);
} else {
FilePicker.onPictureReturnedFromGallery(result, activity, callbacks);
}
}
public void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks);
}
public void onPictureReturnedFromCamera(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
FilePicker.onPictureReturnedFromCamera(result, activity, callbacks);
}
/**
* Attaches callback for file picker.
*/
public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) {
handleActivityResult.onHandleActivityResult(new DefaultCallback() {
@Override
public void onCanceled(final ImageSource source, final int type) {
super.onCanceled(source, type);
defaultKvStore.remove(PLACE_OBJECT);
}
@Override
public void onImagePickerError(Exception e, FilePicker.ImageSource source,
int type) {
ViewUtil.showShortToast(activity, R.string.error_occurred_in_picking_images);
}
@Override
public void onImagesPicked(@NonNull List<UploadableFile> imagesFiles,
FilePicker.ImageSource source, int type) {
Intent intent = handleImagesPicked(activity, imagesFiles);
activity.startActivity(intent);
}
});
}
public List<UploadableFile> handleExternalImagesPicked(Activity activity,
Intent data) {
return FilePicker.handleExternalImagesPicked(data, activity);
}
/**
* Returns intent to be passed to upload activity Attaches place object for nearby uploads and
* location before image capture if in-app camera is used
*/
private Intent handleImagesPicked(Context context,
List<UploadableFile> imagesFiles) {
Intent shareIntent = new Intent(context, UploadActivity.class);
shareIntent.setAction(ACTION_INTERNAL_UPLOADS);
shareIntent
.putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, new ArrayList<>(imagesFiles));
Place place = defaultKvStore.getJson(PLACE_OBJECT, Place.class);
if (place != null) {
shareIntent.putExtra(PLACE_OBJECT, place);
}
if (locationBeforeImageCapture != null) {
shareIntent.putExtra(
UploadActivity.LOCATION_BEFORE_IMAGE_CAPTURE,
locationBeforeImageCapture);
}
shareIntent.putExtra(
UploadActivity.IN_APP_CAMERA_UPLOAD,
isInAppCameraUpload
);
isInAppCameraUpload = false; // reset the flag for next use
return shareIntent;
}
/**
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it
* populates the `pendingContributionList`.
**/
void getPendingContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates(
Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
Contribution.STATE_PAUSED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
pendingContributionList = livePagedListBuilder.build();
}
/**
* Fetches the contributions with the state "FAILED" and populates the
* `failedContributionList`.
**/
void getFailedContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates(
Collections.singletonList(Contribution.STATE_FAILED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
failedContributionList = livePagedListBuilder.build();
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and
* then it populates the `failedAndPendingContributionList`.
**/
// void getFailedAndPendingContributions() {
// final PagedList.Config pagedListConfig =
// (new PagedList.Config.Builder())
// .setPrefetchDistance(50)
// .setPageSize(10).build();
// Factory<Integer, Contribution> factory;
// factory = repository.fetchContributionsWithStates(
// Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
// Contribution.STATE_PAUSED, Contribution.STATE_FAILED));
//
// LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
// pagedListConfig);
// failedAndPendingContributionList = livePagedListBuilder.build();
// }
}

View file

@ -0,0 +1,474 @@
package fr.free.nrw.commons.contributions
import android.Manifest.permission
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LiveData
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import fr.free.nrw.commons.R
import fr.free.nrw.commons.filepicker.DefaultCallback
import fr.free.nrw.commons.filepicker.FilePicker
import fr.free.nrw.commons.filepicker.FilePicker.HandleActivityResult
import fr.free.nrw.commons.filepicker.FilePicker.configuration
import fr.free.nrw.commons.filepicker.FilePicker.handleExternalImagesPicked
import fr.free.nrw.commons.filepicker.FilePicker.onPictureReturnedFromDocuments
import fr.free.nrw.commons.filepicker.FilePicker.openCameraForImage
import fr.free.nrw.commons.filepicker.FilePicker.openCustomSelector
import fr.free.nrw.commons.filepicker.FilePicker.openGallery
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.location.LocationPermissionsHelper
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE
import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import fr.free.nrw.commons.utils.ViewUtil.showShortToast
import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
import java.util.Arrays
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class ContributionController @Inject constructor(@param:Named("default_preferences") private val defaultKvStore: JsonKvStore) {
private var locationBeforeImageCapture: LatLng? = null
private var isInAppCameraUpload = false
@JvmField
var locationPermissionCallback: LocationPermissionCallback? = null
private var locationPermissionsHelper: LocationPermissionsHelper? = null
// Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// LiveData<PagedList<Contribution>> failedAndPendingContributionList;
@JvmField
var pendingContributionList: LiveData<PagedList<Contribution>>? = null
@JvmField
var failedContributionList: LiveData<PagedList<Contribution>>? = null
@JvmField
@Inject
var locationManager: LocationServiceManager? = null
@JvmField
@Inject
var repository: ContributionsRepository? = null
/**
* Check for permissions and initiate camera click
*/
fun initiateCameraPick(
activity: Activity,
inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>,
resultLauncher: ActivityResultLauncher<Intent>
) {
val useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true)
if (!useExtStorage) {
initiateCameraUpload(activity, resultLauncher)
return
}
checkPermissionsAndPerformAction(
activity,
{
if (defaultKvStore.getBoolean("inAppCameraFirstRun")) {
defaultKvStore.putBoolean("inAppCameraFirstRun", false)
askUserToAllowLocationAccess(
activity,
inAppCameraLocationPermissionLauncher,
resultLauncher
)
} else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) {
createDialogsAndHandleLocationPermissions(
activity,
inAppCameraLocationPermissionLauncher, resultLauncher
)
} else {
initiateCameraUpload(activity, resultLauncher)
}
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
*PERMISSIONS_STORAGE
)
}
/**
* Asks users to provide location access
*
* @param activity
*/
private fun createDialogsAndHandleLocationPermissions(
activity: Activity,
inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>?,
resultLauncher: ActivityResultLauncher<Intent>
) {
locationPermissionCallback = object : LocationPermissionCallback {
override fun onLocationPermissionDenied(toastMessage: String) {
Toast.makeText(
activity,
toastMessage,
Toast.LENGTH_LONG
).show()
initiateCameraUpload(activity, resultLauncher)
}
override fun onLocationPermissionGranted() {
if (!locationPermissionsHelper!!.isLocationAccessToAppsTurnedOn()) {
showLocationOffDialog(
activity, R.string.in_app_camera_needs_location,
R.string.in_app_camera_location_unavailable, resultLauncher
)
} else {
initiateCameraUpload(activity, resultLauncher)
}
}
}
locationPermissionsHelper = LocationPermissionsHelper(
activity, locationManager!!, locationPermissionCallback
)
inAppCameraLocationPermissionLauncher?.launch(
arrayOf(permission.ACCESS_FINE_LOCATION)
)
}
/**
* Shows a dialog alerting the user about location services being off and asking them to turn it
* on
* TODO: Add a seperate callback in LocationPermissionsHelper for this.
* Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
*
* @param activity Activity reference
* @param dialogTextResource Resource id of text to be shown in dialog
* @param toastTextResource Resource id of text to be shown in toast
* @param resultLauncher
*/
private fun showLocationOffDialog(
activity: Activity, dialogTextResource: Int,
toastTextResource: Int, resultLauncher: ActivityResultLauncher<Intent>
) {
showAlertDialog(activity,
activity.getString(R.string.ask_to_turn_location_on),
activity.getString(dialogTextResource),
activity.getString(R.string.title_app_shortcut_setting),
activity.getString(R.string.cancel),
{ locationPermissionsHelper!!.openLocationSettings(activity) },
{
Toast.makeText(
activity, activity.getString(toastTextResource),
Toast.LENGTH_LONG
).show()
initiateCameraUpload(activity, resultLauncher)
}
)
}
fun handleShowRationaleFlowCameraLocation(
activity: Activity,
inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>?,
resultLauncher: ActivityResultLauncher<Intent>
) {
showAlertDialog(
activity, activity.getString(R.string.location_permission_title),
activity.getString(R.string.in_app_camera_location_permission_rationale),
activity.getString(android.R.string.ok),
activity.getString(android.R.string.cancel),
{
createDialogsAndHandleLocationPermissions(
activity,
inAppCameraLocationPermissionLauncher, resultLauncher
)
},
{
locationPermissionCallback!!.onLocationPermissionDenied(
activity.getString(R.string.in_app_camera_location_permission_denied)
)
},
null
)
}
/**
* Suggest user to attach location information with pictures. If the user selects "Yes", then:
*
*
* Location is taken from the EXIF if the default camera application does not redact location
* tags.
*
*
* Otherwise, if the EXIF metadata does not have location information, then location captured by
* the app is used
*
* @param activity
*/
private fun askUserToAllowLocationAccess(
activity: Activity,
inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>,
resultLauncher: ActivityResultLauncher<Intent>
) {
showAlertDialog(
activity,
activity.getString(R.string.in_app_camera_location_permission_title),
activity.getString(R.string.in_app_camera_location_access_explanation),
activity.getString(R.string.option_allow),
activity.getString(R.string.option_dismiss),
{
defaultKvStore.putBoolean("inAppCameraLocationPref", true)
createDialogsAndHandleLocationPermissions(
activity,
inAppCameraLocationPermissionLauncher, resultLauncher
)
},
{
showLongToast(activity, R.string.in_app_camera_location_permission_denied)
defaultKvStore.putBoolean("inAppCameraLocationPref", false)
initiateCameraUpload(activity, resultLauncher)
},
null
)
}
/**
* Initiate gallery picker
*/
fun initiateGalleryPick(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>,
allowMultipleUploads: Boolean
) {
initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads)
}
/**
* Initiate gallery picker with permission
*/
fun initiateCustomGalleryPickWithPermission(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>
) {
setPickerConfiguration(activity, true)
checkPermissionsAndPerformAction(
activity,
{ openCustomSelector(activity, resultLauncher, 0) },
R.string.storage_permission_title,
R.string.write_storage_permission_rationale,
*PERMISSIONS_STORAGE
)
}
/**
* Open chooser for gallery uploads
*/
private fun initiateGalleryUpload(
activity: Activity, resultLauncher: ActivityResultLauncher<Intent>,
allowMultipleUploads: Boolean
) {
setPickerConfiguration(activity, allowMultipleUploads)
openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred)
}
/**
* Sets configuration for file picker
*/
private fun setPickerConfiguration(
activity: Activity,
allowMultipleUploads: Boolean
) {
val copyToExternalStorage = defaultKvStore.getBoolean("useExternalStorage", true)
configuration(activity)
.setCopyTakenPhotosToPublicGalleryAppFolder(copyToExternalStorage)
.setAllowMultiplePickInGallery(allowMultipleUploads)
}
/**
* Initiate camera upload by opening camera
*/
private fun initiateCameraUpload(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>
) {
setPickerConfiguration(activity, false)
if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) {
locationBeforeImageCapture = locationManager!!.getLastLocation()
}
isInAppCameraUpload = true
openCameraForImage(activity, resultLauncher, 0)
}
private val isDocumentPhotoPickerPreferred: Boolean
get() = defaultKvStore.getBoolean(
"openDocumentPhotoPickerPref", true
)
fun onPictureReturnedFromGallery(
result: ActivityResult,
activity: Activity,
callbacks: FilePicker.Callbacks
) {
if (isDocumentPhotoPickerPreferred) {
onPictureReturnedFromDocuments(result, activity, callbacks)
} else {
FilePicker.onPictureReturnedFromGallery(result, activity, callbacks)
}
}
fun onPictureReturnedFromCustomSelector(
result: ActivityResult,
activity: Activity,
callbacks: FilePicker.Callbacks
) {
FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks)
}
fun onPictureReturnedFromCamera(
result: ActivityResult,
activity: Activity,
callbacks: FilePicker.Callbacks
) {
FilePicker.onPictureReturnedFromCamera(result, activity, callbacks)
}
/**
* Attaches callback for file picker.
*/
fun handleActivityResultWithCallback(
activity: Activity,
handleActivityResult: HandleActivityResult
) {
handleActivityResult.onHandleActivityResult(object : DefaultCallback() {
override fun onCanceled(source: FilePicker.ImageSource, type: Int) {
super.onCanceled(source, type)
defaultKvStore.remove(PLACE_OBJECT)
}
override fun onImagePickerError(
e: Exception, source: FilePicker.ImageSource,
type: Int
) {
showShortToast(activity, R.string.error_occurred_in_picking_images)
}
override fun onImagesPicked(
imagesFiles: List<UploadableFile>,
source: FilePicker.ImageSource, type: Int
) {
val intent = handleImagesPicked(activity, imagesFiles)
activity.startActivity(intent)
}
})
}
fun handleExternalImagesPicked(
activity: Activity,
data: Intent?
): List<UploadableFile> {
return handleExternalImagesPicked(data, activity)
}
/**
* Returns intent to be passed to upload activity Attaches place object for nearby uploads and
* location before image capture if in-app camera is used
*/
private fun handleImagesPicked(
context: Context,
imagesFiles: List<UploadableFile>
): Intent {
val shareIntent = Intent(context, UploadActivity::class.java)
shareIntent.setAction(ACTION_INTERNAL_UPLOADS)
shareIntent
.putParcelableArrayListExtra(UploadActivity.EXTRA_FILES, ArrayList(imagesFiles))
val place = defaultKvStore.getJson<Place>(PLACE_OBJECT, Place::class.java)
if (place != null) {
shareIntent.putExtra(PLACE_OBJECT, place)
}
if (locationBeforeImageCapture != null) {
shareIntent.putExtra(
UploadActivity.LOCATION_BEFORE_IMAGE_CAPTURE,
locationBeforeImageCapture
)
}
shareIntent.putExtra(
UploadActivity.IN_APP_CAMERA_UPLOAD,
isInAppCameraUpload
)
isInAppCameraUpload = false // reset the flag for next use
return shareIntent
}
val pendingContributions: Unit
/**
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it
* populates the `pendingContributionList`.
*/
get() {
val pagedListConfig =
(PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build()
val factory = repository!!.fetchContributionsWithStates(
Arrays.asList(
Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
Contribution.STATE_PAUSED
)
)
val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig)
pendingContributionList = livePagedListBuilder.build()
}
val failedContributions: Unit
/**
* Fetches the contributions with the state "FAILED" and populates the
* `failedContributionList`.
*/
get() {
val pagedListConfig =
(PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build()
val factory = repository!!.fetchContributionsWithStates(
listOf(Contribution.STATE_FAILED)
)
val livePagedListBuilder = LivePagedListBuilder(factory, pagedListConfig)
failedContributionList = livePagedListBuilder.build()
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and
* then it populates the `failedAndPendingContributionList`.
*/
// void getFailedAndPendingContributions() {
// final PagedList.Config pagedListConfig =
// (new PagedList.Config.Builder())
// .setPrefetchDistance(50)
// .setPageSize(10).build();
// Factory<Integer, Contribution> factory;
// factory = repository.fetchContributionsWithStates(
// Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
// Contribution.STATE_PAUSED, Contribution.STATE_FAILED));
//
// LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
// pagedListConfig);
// failedAndPendingContributionList = livePagedListBuilder.build();
// }
companion object {
const val ACTION_INTERNAL_UPLOADS: String = "internalImageUploads"
}
}

View file

@ -1,145 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.database.sqlite.SQLiteException;
import androidx.paging.DataSource;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Transaction;
import androidx.room.Update;
import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.Calendar;
import java.util.List;
import timber.log.Timber;
@Dao
public abstract class ContributionDao {
@Query("SELECT * FROM contribution order by media_dateUploaded DESC")
abstract DataSource.Factory<Integer, Contribution> fetchContributions();
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void saveSynchronous(Contribution contribution);
public Completable save(final Contribution contribution) {
return Completable
.fromAction(() -> {
contribution.setDateModified(Calendar.getInstance().getTime());
if (contribution.getDateUploadStarted() == null) {
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
}
saveSynchronous(contribution);
});
}
@Transaction
public void deleteAndSaveContribution(final Contribution oldContribution,
final Contribution newContribution) {
deleteSynchronous(oldContribution);
saveSynchronous(newContribution);
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract Single<List<Long>> save(List<Contribution> contribution);
@Delete
public abstract void deleteSynchronous(Contribution contribution);
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @throws SQLiteException If an SQLite error occurs.
*/
@Query("DELETE FROM contribution WHERE state IN (:states)")
public abstract void deleteContributionsWithStatesSynchronous(List<Integer> states)
throws SQLiteException;
public Completable delete(final Contribution contribution) {
return Completable
.fromAction(() -> deleteSynchronous(contribution));
}
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsWithStates(List<Integer> states) {
return Completable
.fromAction(() -> deleteContributionsWithStatesSynchronous(states));
}
@Query("SELECT * from contribution WHERE media_filename=:fileName")
public abstract List<Contribution> getContributionWithTitle(String fileName);
@Query("SELECT * from contribution WHERE pageId=:pageId")
public abstract Contribution getContribution(String pageId);
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
public abstract Single<List<Contribution>> getContribution(List<Integer> states);
/**
* Gets contributions with specific states in descending order by the date they were uploaded.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
public abstract DataSource.Factory<Integer, Contribution> getContributions(
List<Integer> states);
/**
* Gets contributions with specific states in ascending order by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC")
public abstract DataSource.Factory<Integer, Contribution> getContributionsSortedByDateUploadStarted(
List<Integer> states);
@Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)")
public abstract Single<Integer> getPendingUploads(int[] toUpdateStates);
@Query("Delete FROM contribution")
public abstract void deleteAll() throws SQLiteException;
@Update
public abstract void updateSynchronous(Contribution contribution);
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
*/
@Query("UPDATE contribution SET state = :newState WHERE state IN (:states)")
public abstract void updateContributionsState(List<Integer> states, int newState);
public Completable update(final Contribution contribution) {
return Completable
.fromAction(() -> {
contribution.setDateModified(Calendar.getInstance().getTime());
updateSynchronous(contribution);
});
}
/**
* Updates the state of contributions with specific states asynchronously.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return Completable
.fromAction(() -> {
updateContributionsState(states, newState);
});
}
}

View file

@ -0,0 +1,148 @@
package fr.free.nrw.commons.contributions
import android.database.sqlite.SQLiteException
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.reactivex.Completable
import io.reactivex.Single
import java.util.Calendar
@Dao
abstract class ContributionDao {
@Query("SELECT * FROM contribution order by media_dateUploaded DESC")
abstract fun fetchContributions(): DataSource.Factory<Int, Contribution>
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun saveSynchronous(contribution: Contribution)
fun save(contribution: Contribution): Completable {
return Completable
.fromAction {
contribution.dateModified = Calendar.getInstance().time
if (contribution.dateUploadStarted == null) {
contribution.dateUploadStarted = Calendar.getInstance().time
}
saveSynchronous(contribution)
}
}
@Transaction
open fun deleteAndSaveContribution(
oldContribution: Contribution,
newContribution: Contribution
) {
deleteSynchronous(oldContribution)
saveSynchronous(newContribution)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun save(contribution: List<Contribution>): Single<List<Long>>
@Delete
abstract fun deleteSynchronous(contribution: Contribution)
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @throws SQLiteException If an SQLite error occurs.
*/
@Query("DELETE FROM contribution WHERE state IN (:states)")
@Throws(SQLiteException::class)
abstract fun deleteContributionsWithStatesSynchronous(states: List<Int>)
fun delete(contribution: Contribution): Completable {
return Completable
.fromAction { deleteSynchronous(contribution) }
}
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
fun deleteContributionsWithStates(states: List<Int>): Completable {
return Completable
.fromAction { deleteContributionsWithStatesSynchronous(states) }
}
@Query("SELECT * from contribution WHERE media_filename=:fileName")
abstract fun getContributionWithTitle(fileName: String): List<Contribution>
@Query("SELECT * from contribution WHERE pageId=:pageId")
abstract fun getContribution(pageId: String): Contribution
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
abstract fun getContribution(states: List<Int>): Single<List<Contribution>>
/**
* Gets contributions with specific states in descending order by the date they were uploaded.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
abstract fun getContributions(
states: List<Int>
): DataSource.Factory<Int, Contribution>
/**
* Gets contributions with specific states in ascending order by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC")
abstract fun getContributionsSortedByDateUploadStarted(
states: List<Int>
): DataSource.Factory<Int, Contribution>
@Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)")
abstract fun getPendingUploads(toUpdateStates: IntArray): Single<Int>
@Query("Delete FROM contribution")
@Throws(SQLiteException::class)
abstract fun deleteAll()
@Update
abstract fun updateSynchronous(contribution: Contribution)
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
*/
@Query("UPDATE contribution SET state = :newState WHERE state IN (:states)")
abstract fun updateContributionsState(states: List<Int>, newState: Int)
fun update(contribution: Contribution): Completable {
return Completable.fromAction {
contribution.dateModified = Calendar.getInstance().time
updateSynchronous(contribution)
}
}
/**
* Updates the state of contributions with specific states asynchronously.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
fun updateContributionsWithStates(states: List<Int>, newState: Int): Completable {
return Completable
.fromAction {
updateContributionsState(states, newState)
}
}
}

View file

@ -1,171 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.webkit.URLUtil;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AlertDialog.Builder;
import androidx.recyclerview.widget.RecyclerView;
import com.facebook.drawee.view.SimpleDraweeView;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback;
import fr.free.nrw.commons.databinding.LayoutContributionBinding;
import fr.free.nrw.commons.media.MediaClient;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import java.io.File;
public class ContributionViewHolder extends RecyclerView.ViewHolder {
private final Callback callback;
LayoutContributionBinding binding;
private int position;
private Contribution contribution;
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
private final MediaClient mediaClient;
private boolean isWikipediaButtonDisplayed;
private AlertDialog pausingPopUp;
private View parent;
private ImageRequest imageRequest;
ContributionViewHolder(final View parent, final Callback callback,
final MediaClient mediaClient) {
super(parent);
this.parent = parent;
this.mediaClient = mediaClient;
this.callback = callback;
binding = LayoutContributionBinding.bind(parent);
binding.contributionImage.setOnClickListener(v -> imageClicked());
binding.wikipediaButton.setOnClickListener(v -> wikipediaButtonClicked());
/* Set a dialog indicating that the upload is being paused. This is needed because pausing
an upload might take a dozen seconds. */
AlertDialog.Builder builder = new Builder(parent.getContext());
builder.setCancelable(false);
builder.setView(R.layout.progress_dialog);
pausingPopUp = builder.create();
}
public void init(final int position, final Contribution contribution) {
//handling crashes when the contribution is null.
if (null == contribution) {
return;
}
this.contribution = contribution;
this.position = position;
binding.contributionTitle.setText(contribution.getMedia().getMostRelevantCaption());
binding.authorView.setText(contribution.getMedia().getAuthor());
//Removes flicker of loading image.
binding.contributionImage.getHierarchy().setFadeDuration(0);
binding.contributionImage.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder);
binding.contributionImage.getHierarchy().setFailureImage(R.drawable.image_placeholder);
final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(),
contribution.getLocalUri());
if (!TextUtils.isEmpty(imageSource)) {
if (URLUtil.isHttpsUrl(imageSource)) {
imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource))
.setProgressiveRenderingEnabled(true)
.build();
} else if (URLUtil.isFileUrl(imageSource)) {
imageRequest = ImageRequest.fromUri(Uri.parse(imageSource));
} else if (imageSource != null) {
final File file = new File(imageSource);
imageRequest = ImageRequest.fromFile(file);
}
if (imageRequest != null) {
binding.contributionImage.setImageRequest(imageRequest);
}
}
binding.contributionSequenceNumber.setText(String.valueOf(position + 1));
binding.contributionSequenceNumber.setVisibility(View.VISIBLE);
binding.wikipediaButton.setVisibility(View.GONE);
binding.contributionState.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.GONE);
binding.contributionState.setText("");
checkIfMediaExistsOnWikipediaPage(contribution);
}
/**
* Checks if a media exists on the corresponding Wikipedia article Currently the check is made
* for the device's current language Wikipedia
*
* @param contribution
*/
private void checkIfMediaExistsOnWikipediaPage(final Contribution contribution) {
if (contribution.getWikidataPlace() == null
|| contribution.getWikidataPlace().getWikipediaArticle() == null) {
return;
}
final String wikipediaArticle = contribution.getWikidataPlace().getWikipediaPageTitle();
compositeDisposable.add(mediaClient.doesPageContainMedia(wikipediaArticle)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(mediaExists -> {
displayWikipediaButton(mediaExists);
}));
}
/**
* Handle action buttons visibility if the corresponding wikipedia page doesn't contain any
* media. This method needs to control the state of just the scenario where media does not
* exists as other scenarios are already handled in the init method.
*
* @param mediaExists
*/
private void displayWikipediaButton(Boolean mediaExists) {
if (!mediaExists) {
binding.wikipediaButton.setVisibility(View.VISIBLE);
isWikipediaButtonDisplayed = true;
binding.imageOptions.setVisibility(View.VISIBLE);
}
}
/**
* Returns the image source for the image view, first preference is given to thumbUrl if that is
* null, moves to local uri and if both are null return null
*
* @param thumbUrl
* @param localUri
* @return
*/
@Nullable
private String chooseImageSource(final String thumbUrl, final Uri localUri) {
return !TextUtils.isEmpty(thumbUrl) ? thumbUrl :
localUri != null ? localUri.toString() :
null;
}
public void imageClicked() {
callback.openMediaDetail(position, isWikipediaButtonDisplayed);
}
public void wikipediaButtonClicked() {
callback.addImageToWikipedia(contribution);
}
public ImageRequest getImageRequest() {
return imageRequest;
}
}

View file

@ -0,0 +1,152 @@
package fr.free.nrw.commons.contributions
import android.net.Uri
import android.text.TextUtils
import android.view.View
import android.webkit.URLUtil
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest
import com.facebook.imagepipeline.request.ImageRequestBuilder
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.LayoutContributionBinding
import fr.free.nrw.commons.media.MediaClient
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import java.io.File
class ContributionViewHolder internal constructor(
private val parent: View, private val callback: ContributionsListAdapter.Callback,
private val mediaClient: MediaClient
) : RecyclerView.ViewHolder(parent) {
var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent)
private var position = 0
private var contribution: Contribution? = null
private val compositeDisposable = CompositeDisposable()
private var isWikipediaButtonDisplayed = false
private val pausingPopUp: AlertDialog
var imageRequest: ImageRequest? = null
private set
init {
binding.contributionImage.setOnClickListener { v: View? -> imageClicked() }
binding.wikipediaButton.setOnClickListener { v: View? -> wikipediaButtonClicked() }
/* Set a dialog indicating that the upload is being paused. This is needed because pausing
an upload might take a dozen seconds. */
val builder = AlertDialog.Builder(
parent.context
)
builder.setCancelable(false)
builder.setView(R.layout.progress_dialog)
pausingPopUp = builder.create()
}
fun init(position: Int, contribution: Contribution?) {
//handling crashes when the contribution is null.
if (null == contribution) {
return
}
this.contribution = contribution
this.position = position
binding.contributionTitle.text = contribution.media.mostRelevantCaption
binding.authorView.text = contribution.media.author
//Removes flicker of loading image.
binding.contributionImage.hierarchy.fadeDuration = 0
binding.contributionImage.hierarchy.setPlaceholderImage(R.drawable.image_placeholder)
binding.contributionImage.hierarchy.setFailureImage(R.drawable.image_placeholder)
val imageSource = chooseImageSource(
contribution.media.thumbUrl,
contribution.localUri
)
if (!TextUtils.isEmpty(imageSource)) {
if (URLUtil.isHttpsUrl(imageSource)) {
imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource))
.setProgressiveRenderingEnabled(true)
.build()
} else if (URLUtil.isFileUrl(imageSource)) {
imageRequest = ImageRequest.fromUri(Uri.parse(imageSource))
} else if (imageSource != null) {
val file = File(imageSource)
imageRequest = ImageRequest.fromFile(file)
}
if (imageRequest != null) {
binding.contributionImage.setImageRequest(imageRequest)
}
}
binding.contributionSequenceNumber.text = (position + 1).toString()
binding.contributionSequenceNumber.visibility = View.VISIBLE
binding.wikipediaButton.visibility = View.GONE
binding.contributionState.visibility = View.GONE
binding.contributionProgress.visibility = View.GONE
binding.imageOptions.visibility = View.GONE
binding.contributionState.text = ""
checkIfMediaExistsOnWikipediaPage(contribution)
}
/**
* Checks if a media exists on the corresponding Wikipedia article Currently the check is made
* for the device's current language Wikipedia
*
* @param contribution
*/
private fun checkIfMediaExistsOnWikipediaPage(contribution: Contribution) {
if (contribution.wikidataPlace == null
|| contribution.wikidataPlace!!.wikipediaArticle == null
) {
return
}
val wikipediaArticle = contribution.wikidataPlace!!.getWikipediaPageTitle()
compositeDisposable.add(
mediaClient.doesPageContainMedia(wikipediaArticle)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { mediaExists: Boolean ->
displayWikipediaButton(mediaExists)
})
}
/**
* Handle action buttons visibility if the corresponding wikipedia page doesn't contain any
* media. This method needs to control the state of just the scenario where media does not
* exists as other scenarios are already handled in the init method.
*
* @param mediaExists
*/
private fun displayWikipediaButton(mediaExists: Boolean) {
if (!mediaExists) {
binding.wikipediaButton.visibility = View.VISIBLE
isWikipediaButtonDisplayed = true
binding.imageOptions.visibility = View.VISIBLE
}
}
/**
* Returns the image source for the image view, first preference is given to thumbUrl if that is
* null, moves to local uri and if both are null return null
*
* @param thumbUrl
* @param localUri
* @return
*/
private fun chooseImageSource(thumbUrl: String?, localUri: Uri?): String? {
return if (!TextUtils.isEmpty(thumbUrl)) thumbUrl else localUri?.toString()
}
fun imageClicked() {
callback.openMediaDetail(position, isWikipediaButtonDisplayed)
}
fun wikipediaButtonClicked() {
callback.addImageToWikipedia(contribution)
}
}

View file

@ -1,23 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.content.Context;
import fr.free.nrw.commons.BasePresenter;
/**
* The contract for Contributions View & Presenter
*/
public class ContributionsContract {
public interface View {
void showMessage(String localizedMessage);
Context getContext();
}
public interface UserActionListener extends BasePresenter<ContributionsContract.View> {
Contribution getContributionsWithTitle(String uri);
}
}

View file

@ -0,0 +1,19 @@
package fr.free.nrw.commons.contributions
import android.content.Context
import fr.free.nrw.commons.BasePresenter
/**
* The contract for Contributions View & Presenter
*/
interface ContributionsContract {
interface View {
fun showMessage(localizedMessage: String)
fun getContext(): Context
}
interface UserActionListener : BasePresenter<View> {
fun getContributionsWithTitle(uri: String): Contribution
}
}

View file

@ -1,940 +0,0 @@
package fr.free.nrw.commons.contributions;
import static android.content.Context.SENSOR_SERVICE;
import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED;
import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED;
import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL;
import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME;
import static fr.free.nrw.commons.utils.LengthUtils.computeBearing;
import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween;
import android.Manifest;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
import androidx.fragment.app.FragmentTransaction;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.databinding.FragmentContributionsBinding;
import fr.free.nrw.commons.notification.models.Notification;
import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import androidx.work.WorkManager;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.campaigns.models.Campaign;
import fr.free.nrw.commons.campaigns.CampaignView;
import fr.free.nrw.commons.campaigns.CampaignsPresenter;
import fr.free.nrw.commons.campaigns.ICampaignsView;
import fr.free.nrw.commons.contributions.ContributionsListFragment.Callback;
import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.location.LocationUpdateListener;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.nearby.NearbyController;
import fr.free.nrw.commons.nearby.NearbyNotificationCardView;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.notification.NotificationActivity;
import fr.free.nrw.commons.upload.worker.UploadWorker;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
public class ContributionsFragment
extends CommonsDaggerSupportFragment
implements
OnBackStackChangedListener,
LocationUpdateListener,
MediaDetailProvider,
SensorEventListener,
ICampaignsView, ContributionsContract.View, Callback {
@Inject
@Named("default_preferences")
JsonKvStore store;
@Inject
NearbyController nearbyController;
@Inject
OkHttpJsonApiClient okHttpJsonApiClient;
@Inject
CampaignsPresenter presenter;
@Inject
LocationServiceManager locationManager;
@Inject
NotificationController notificationController;
@Inject
ContributionController contributionController;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
private ContributionsListFragment contributionsListFragment;
private static final String CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag";
private MediaDetailPagerFragment mediaDetailPagerFragment;
static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag";
private static final int MAX_RETRIES = 10;
public FragmentContributionsBinding binding;
@Inject
ContributionsPresenter contributionsPresenter;
@Inject
SessionManager sessionManager;
private LatLng currentLatLng;
private boolean isFragmentAttachedBefore = false;
private View checkBoxView;
private CheckBox checkBox;
public TextView notificationCount;
public TextView pendingUploadsCountTextView;
public TextView uploadsErrorTextView;
public ImageView pendingUploadsImageView;
private Campaign wlmCampaign;
String userName;
private boolean isUserProfile;
private SensorManager mSensorManager;
private Sensor mLight;
private float direction;
private ActivityResultLauncher<String[]> nearbyLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(
Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment
== ActiveFragment.CONTRIBUTIONS)) {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
} else {
displayYouWontSeeNearbyMessage();
}
}
}
});
@NonNull
public static ContributionsFragment newInstance() {
ContributionsFragment fragment = new ContributionsFragment();
fragment.setRetainInstance(true);
return fragment;
}
private boolean shouldShowMediaDetailsFragment;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null && getArguments().getString(KEY_USERNAME) != null) {
userName = getArguments().getString(KEY_USERNAME);
isUserProfile = true;
}
mSensorManager = (SensorManager) getActivity().getSystemService(SENSOR_SERVICE);
mLight = mSensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentContributionsBinding.inflate(inflater, container, false);
initWLMCampaign();
presenter.onAttachView(this);
contributionsPresenter.onAttachView(this);
binding.campaignsView.setVisibility(View.GONE);
checkBoxView = View.inflate(getActivity(), R.layout.nearby_permission_dialog, null);
checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again);
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
// Do not ask for permission on activity start again
store.putBoolean("displayLocationPermissionForCardView", false);
}
});
if (savedInstanceState != null) {
mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager()
.findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG);
contributionsListFragment = (ContributionsListFragment) getChildFragmentManager()
.findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG);
shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible");
}
initFragments();
if (!isUserProfile) {
upDateUploadCount();
}
if (shouldShowMediaDetailsFragment) {
showMediaDetailPagerFragment();
} else {
if (mediaDetailPagerFragment != null) {
removeFragment(mediaDetailPagerFragment);
}
showContributionsListFragment();
}
if (!ConfigUtils.isBetaFlavour() && sessionManager.isUserLoggedIn()
&& sessionManager.getCurrentAccount() != null && !isUserProfile) {
setUploadCount();
}
setHasOptionsMenu(true);
return binding.getRoot();
}
/**
* Initialise the campaign object for WML
*/
private void initWLMCampaign() {
wlmCampaign = new Campaign(getString(R.string.wlm_campaign_title),
getString(R.string.wlm_campaign_description), Utils.getWLMStartDate().toString(),
Utils.getWLMEndDate().toString(), WLM_URL, true);
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
// Removing contributions menu items for ProfileActivity
if (getActivity() instanceof ProfileActivity) {
return;
}
inflater.inflate(R.menu.contribution_activity_notification_menu, menu);
MenuItem notificationsMenuItem = menu.findItem(R.id.notifications);
final View notification = notificationsMenuItem.getActionView();
notificationCount = notification.findViewById(R.id.notification_count_badge);
MenuItem uploadMenuItem = menu.findItem(R.id.upload_tab);
final View uploadMenuItemActionView = uploadMenuItem.getActionView();
pendingUploadsCountTextView = uploadMenuItemActionView.findViewById(
R.id.pending_uploads_count_badge);
uploadsErrorTextView = uploadMenuItemActionView.findViewById(
R.id.uploads_error_count_badge);
pendingUploadsImageView = uploadMenuItemActionView.findViewById(
R.id.pending_uploads_image_view);
if (pendingUploadsImageView != null) {
pendingUploadsImageView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
if (pendingUploadsCountTextView != null) {
pendingUploadsCountTextView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
if (uploadsErrorTextView != null) {
uploadsErrorTextView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
notification.setOnClickListener(view -> {
NotificationActivity.Companion.startYourself(getContext(), "unread");
});
}
@SuppressLint("CheckResult")
public void setNotificationCount() {
compositeDisposable.add(notificationController.getNotifications(false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::initNotificationViews,
throwable -> Timber.e(throwable, "Error occurred while loading notifications")));
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Sets the visibility of the upload icon based on the number of failed and pending
* contributions.
*/
// public void setUploadIconVisibility() {
// contributionController.getFailedAndPendingContributions();
// contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(),
// list -> {
// updateUploadIcon(list.size());
// });
// }
/**
* Sets the count for the upload icon based on the number of pending and failed contributions.
*/
public void setUploadIconCount() {
contributionController.getPendingContributions();
contributionController.pendingContributionList.observe(getViewLifecycleOwner(),
list -> {
updatePendingIcon(list.size());
});
contributionController.getFailedContributions();
contributionController.failedContributionList.observe(getViewLifecycleOwner(), list -> {
updateErrorIcon(list.size());
});
}
public void scrollToTop() {
if (contributionsListFragment != null) {
contributionsListFragment.scrollToTop();
}
}
private void initNotificationViews(List<Notification> notificationList) {
Timber.d("Number of notifications is %d", notificationList.size());
if (notificationList.isEmpty()) {
notificationCount.setVisibility(View.GONE);
} else {
notificationCount.setVisibility(View.VISIBLE);
notificationCount.setText(String.valueOf(notificationList.size()));
}
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
/*
- There are some operations we need auth, so we need to make sure isAuthCookieAcquired.
- And since we use same retained fragment doesn't want to make all network operations
all over again on same fragment attached to recreated activity, we do this network
operations on first time fragment attached to an activity. Then they will be retained
until fragment life time ends.
*/
if (!isFragmentAttachedBefore && getActivity() != null) {
isFragmentAttachedBefore = true;
}
}
/**
* Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates
* new one if null.
*/
private void showContributionsListFragment() {
// show nearby card view on contributions list is visible
if (binding.cardViewNearby != null && !isUserProfile) {
if (store.getBoolean("displayNearbyCardView", true)) {
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
binding.cardViewNearby.setVisibility(View.GONE);
}
}
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
}
private void showMediaDetailPagerFragment() {
// hide nearby card view on media detail is visible
setupViewForMediaDetails();
showFragment(mediaDetailPagerFragment, MEDIA_DETAIL_PAGER_FRAGMENT_TAG,
contributionsListFragment);
}
private void setupViewForMediaDetails() {
if (binding != null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
@Override
public void onBackStackChanged() {
fetchCampaigns();
}
private void initFragments() {
if (null == contributionsListFragment) {
contributionsListFragment = new ContributionsListFragment();
Bundle contributionsListBundle = new Bundle();
contributionsListBundle.putString(KEY_USERNAME, userName);
contributionsListFragment.setArguments(contributionsListBundle);
}
if (shouldShowMediaDetailsFragment) {
showMediaDetailPagerFragment();
} else {
showContributionsListFragment();
}
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
}
/**
* Replaces the root frame layout with the given fragment
*
* @param fragment
* @param tag
* @param otherFragment
*/
private void showFragment(Fragment fragment, String tag, Fragment otherFragment) {
FragmentTransaction transaction = getChildFragmentManager().beginTransaction();
if (fragment.isAdded() && otherFragment != null) {
transaction.hide(otherFragment);
transaction.show(fragment);
transaction.addToBackStack(tag);
transaction.commit();
getChildFragmentManager().executePendingTransactions();
} else if (fragment.isAdded() && otherFragment == null) {
transaction.show(fragment);
transaction.addToBackStack(tag);
transaction.commit();
getChildFragmentManager().executePendingTransactions();
} else if (!fragment.isAdded() && otherFragment != null) {
transaction.hide(otherFragment);
transaction.add(R.id.root_frame, fragment, tag);
transaction.addToBackStack(tag);
transaction.commit();
getChildFragmentManager().executePendingTransactions();
} else if (!fragment.isAdded()) {
transaction.replace(R.id.root_frame, fragment, tag);
transaction.addToBackStack(tag);
transaction.commit();
getChildFragmentManager().executePendingTransactions();
}
}
public void removeFragment(Fragment fragment) {
getChildFragmentManager()
.beginTransaction()
.remove(fragment)
.commit();
getChildFragmentManager().executePendingTransactions();
}
@SuppressWarnings("ConstantConditions")
private void setUploadCount() {
compositeDisposable.add(okHttpJsonApiClient
.getUploadCount(((MainActivity) getActivity()).sessionManager.getCurrentAccount().name)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayUploadCount,
t -> Timber.e(t, "Fetching upload count failed")
));
}
private void displayUploadCount(Integer uploadCount) {
if (getActivity().isFinishing()
|| getResources() == null) {
return;
}
((MainActivity) getActivity()).setNumOfUploads(uploadCount);
}
@Override
public void onPause() {
super.onPause();
locationManager.removeLocationListener(this);
locationManager.unregisterLocationManager();
mSensorManager.unregisterListener(this);
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
}
@Override
public void onResume() {
super.onResume();
contributionsPresenter.onAttachView(this);
locationManager.addLocationListener(this);
if (binding == null) {
return;
}
binding.cardViewNearby.permissionRequestButton.setOnClickListener(v -> {
showNearbyCardPermissionRationale();
});
// Notification cards should only be seen on contributions list, not in media details
if (mediaDetailPagerFragment == null && !isUserProfile) {
if (store.getBoolean("displayNearbyCardView", true)) {
checkPermissionsAndShowNearbyCardView();
// Calling nearby card to keep showing it even when user clicks on it and comes back
try {
updateClosestNearbyCardViewInfo();
} catch (Exception e) {
Timber.e(e);
}
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
// Hide nearby notification card view if related shared preferences is false
binding.cardViewNearby.setVisibility(View.GONE);
}
// Notification Count and Campaigns should not be set, if it is used in User Profile
if (!isUserProfile) {
setNotificationCount();
fetchCampaigns();
// Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// setUploadIconVisibility();
setUploadIconCount();
}
}
mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI);
}
private void checkPermissionsAndShowNearbyCardView() {
if (PermissionUtils.hasPermission(getActivity(),
new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
onLocationPermissionGranted();
} else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
showNearbyCardPermissionRationale();
}
}
private void requestLocationPermission() {
nearbyLocationPermissionLauncher.launch(new String[]{permission.ACCESS_FINE_LOCATION});
}
private void onLocationPermissionGranted() {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED;
locationManager.registerLocationManager();
}
private void showNearbyCardPermissionRationale() {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.nearby_card_permission_title),
getString(R.string.nearby_card_permission_explanation),
this::requestLocationPermission,
this::displayYouWontSeeNearbyMessage,
checkBoxView
);
}
private void displayYouWontSeeNearbyMessage() {
ViewUtil.showLongToast(getActivity(),
getResources().getString(R.string.unable_to_display_nearest_place));
// Set to true as the user doesn't want the app to ask for location permission anymore
store.putBoolean("doNotAskForLocationPermission", true);
}
private void updateClosestNearbyCardViewInfo() {
currentLatLng = locationManager.getLastLocation();
compositeDisposable.add(Observable.fromCallable(() -> nearbyController
.loadAttractionsFromLocation(currentLatLng, currentLatLng, true,
false)) // thanks to boolean, it will only return closest result
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateNearbyNotification,
throwable -> {
Timber.d(throwable);
updateNearbyNotification(null);
}));
}
private void updateNearbyNotification(
@Nullable NearbyController.NearbyPlacesInfo nearbyPlacesInfo) {
if (nearbyPlacesInfo != null && nearbyPlacesInfo.placeList != null
&& nearbyPlacesInfo.placeList.size() > 0) {
Place closestNearbyPlace = null;
// Find the first nearby place that has no image and exists
for (Place place : nearbyPlacesInfo.placeList) {
if (place.pic.equals("") && place.exists) {
closestNearbyPlace = place;
break;
}
}
if (closestNearbyPlace == null) {
binding.cardViewNearby.setVisibility(View.GONE);
} else {
String distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location);
closestNearbyPlace.setDistance(distance);
direction = (float) computeBearing(currentLatLng, closestNearbyPlace.location);
binding.cardViewNearby.updateContent(closestNearbyPlace);
}
} else {
// Means that no close nearby place is found
binding.cardViewNearby.setVisibility(View.GONE);
}
// Prevent Nearby banner from appearing in Media Details, fixing bug https://github.com/commons-app/apps-android-commons/issues/4731
if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) {
binding.cardViewNearby.setVisibility(View.GONE);
}
}
@Override
public void onDestroy() {
try {
compositeDisposable.clear();
getChildFragmentManager().removeOnBackStackChangedListener(this);
locationManager.unregisterLocationManager();
locationManager.removeLocationListener(this);
super.onDestroy();
} catch (IllegalArgumentException | IllegalStateException exception) {
Timber.e(exception);
}
}
@Override
public void onLocationChangedSignificantly(LatLng latLng) {
// Will be called if location changed more than 1000 meter
updateClosestNearbyCardViewInfo();
}
@Override
public void onLocationChangedSlightly(LatLng latLng) {
/* Update closest nearby notification card onLocationChangedSlightly
*/
try {
updateClosestNearbyCardViewInfo();
} catch (Exception e) {
Timber.e(e);
}
}
@Override
public void onLocationChangedMedium(LatLng latLng) {
// Update closest nearby card view if location changed more than 500 meters
updateClosestNearbyCardViewInfo();
}
@Override
public void onViewCreated(@NonNull View view,
@Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
}
/**
* As the home screen has limited space, we have choosen to show either campaigns or WLM card.
* The WLM Card gets the priority over monuments, so if the WLM is going on we show that instead
* of campaigns on the campaigns card
*/
private void fetchCampaigns() {
if (Utils.isMonumentsEnabled(new Date())) {
if (binding != null) {
binding.campaignsView.setCampaign(wlmCampaign);
binding.campaignsView.setVisibility(View.VISIBLE);
}
} else if (store.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) {
presenter.getCampaigns();
} else {
if (binding != null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
}
@Override
public void showMessage(String message) {
Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
}
@Override
public void showCampaigns(Campaign campaign) {
if (campaign != null && !isUserProfile) {
if (binding != null) {
binding.campaignsView.setCampaign(campaign);
}
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
presenter.onDetachView();
}
@Override
public void notifyDataSetChanged() {
if (mediaDetailPagerFragment != null) {
mediaDetailPagerFragment.notifyDataSetChanged();
}
}
/**
* Notify the viewpager that number of items have changed.
*/
@Override
public void viewPagerNotifyDataSetChanged() {
if (mediaDetailPagerFragment != null) {
mediaDetailPagerFragment.notifyDataSetChanged();
}
}
/**
* Updates the visibility and text of the pending uploads count TextView based on the given
* count.
*
* @param pendingCount The number of pending uploads.
*/
public void updatePendingIcon(int pendingCount) {
if (pendingUploadsCountTextView != null) {
if (pendingCount != 0) {
pendingUploadsCountTextView.setVisibility(View.VISIBLE);
pendingUploadsCountTextView.setText(String.valueOf(pendingCount));
} else {
pendingUploadsCountTextView.setVisibility(View.INVISIBLE);
}
}
}
/**
* Updates the visibility and text of the error uploads TextView based on the given count.
*
* @param errorCount The number of error uploads.
*/
public void updateErrorIcon(int errorCount) {
if (uploadsErrorTextView != null) {
if (errorCount != 0) {
uploadsErrorTextView.setVisibility(View.VISIBLE);
uploadsErrorTextView.setText(String.valueOf(errorCount));
} else {
uploadsErrorTextView.setVisibility(View.GONE);
}
}
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* @param count The number of pending uploads.
*/
// public void updateUploadIcon(int count) {
// if (pendingUploadsImageView != null) {
// if (count != 0) {
// pendingUploadsImageView.setVisibility(View.VISIBLE);
// } else {
// pendingUploadsImageView.setVisibility(View.GONE);
// }
// }
// }
/**
* Replace whatever is in the current contributionsFragmentContainer view with
* mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects
* a contribution.
*/
@Override
public void showDetail(int position, boolean isWikipediaButtonDisplayed) {
if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment.isVisible()) {
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true);
if (isUserProfile) {
((ProfileActivity) getActivity()).setScroll(false);
}
showMediaDetailPagerFragment();
}
mediaDetailPagerFragment.showImage(position, isWikipediaButtonDisplayed);
}
@Override
public Media getMediaAtPosition(int i) {
return contributionsListFragment.getMediaAtPosition(i);
}
@Override
public int getTotalMediaCount() {
return contributionsListFragment.getTotalMediaCount();
}
@Override
public Integer getContributionStateAt(int position) {
return contributionsListFragment.getContributionStateAt(position);
}
public boolean backButtonClicked() {
if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) {
if (store.getBoolean("displayNearbyCardView", true) && !isUserProfile) {
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
binding.cardViewNearby.setVisibility(View.GONE);
}
removeFragment(mediaDetailPagerFragment);
showFragment(contributionsListFragment, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment);
if (isUserProfile) {
// Fragment is associated with ProfileActivity
// Enable ParentViewPager Scroll
((ProfileActivity) getActivity()).setScroll(true);
} else {
fetchCampaigns();
}
if (getActivity() instanceof MainActivity) {
// Fragment is associated with MainActivity
((BaseActivity) getActivity()).getSupportActionBar()
.setDisplayHomeAsUpEnabled(false);
((MainActivity) getActivity()).showTabs();
}
return true;
}
return false;
}
// Getter for mediaDetailPagerFragment
public MediaDetailPagerFragment getMediaDetailPagerFragment() {
return mediaDetailPagerFragment;
}
/**
* this function updates the number of contributions
*/
void upDateUploadCount() {
WorkManager.getInstance(getContext())
.getWorkInfosForUniqueWorkLiveData(UploadWorker.class.getSimpleName()).observe(
getViewLifecycleOwner(), workInfos -> {
if (workInfos.size() > 0) {
setUploadCount();
}
});
}
/**
* Restarts the upload process for a contribution
*
* @param contribution
*/
public void restartUpload(Contribution contribution) {
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
if (contribution.getState() == Contribution.STATE_FAILED) {
if (contribution.getErrorInfo() == null) {
contribution.setChunkInfo(null);
contribution.setTransferred(0);
}
contributionsPresenter.checkDuplicateImageAndRestartContribution(contribution);
} else {
contribution.setState(Contribution.STATE_QUEUED);
contributionsPresenter.saveContribution(contribution);
Timber.d("Restarting for %s", contribution.toString());
}
}
/**
* Retry upload when it is failed
*
* @param contribution contribution to be retried
*/
public void retryUpload(Contribution contribution) {
if (NetworkUtils.isInternetConnectionEstablished(getContext())) {
if (contribution.getState() == STATE_PAUSED) {
restartUpload(contribution);
} else if (contribution.getState() == STATE_FAILED) {
int retries = contribution.getRetries();
// TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562
/* Limit the number of retries for a failed upload
to handle cases like invalid filename as such uploads
will never be successful */
if (retries < MAX_RETRIES) {
contribution.setRetries(retries + 1);
Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(),
retries + 1);
restartUpload(contribution);
} else {
// TODO: Show the exact reason for failure
Toast.makeText(getContext(),
R.string.retry_limit_reached, Toast.LENGTH_SHORT).show();
}
} else {
Timber.d("Skipping re-upload for non-failed %s", contribution.toString());
}
} else {
ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection);
}
}
/**
* Reload media detail fragment once media is nominated
*
* @param index item position that has been nominated
*/
@Override
public void refreshNominatedMedia(int index) {
if (mediaDetailPagerFragment != null && !contributionsListFragment.isVisible()) {
removeFragment(mediaDetailPagerFragment);
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true);
mediaDetailPagerFragment.showImage(index);
showMediaDetailPagerFragment();
}
}
/**
* When the device rotates, rotate the Nearby banner's compass arrow in tandem.
*/
@Override
public void onSensorChanged(SensorEvent event) {
float rotateDegree = Math.round(event.values[0]);
binding.cardViewNearby.rotateCompass(rotateDegree, direction);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// Nothing to do.
}
}

View file

@ -0,0 +1,998 @@
package fr.free.nrw.commons.contributions
import android.Manifest.permission
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.CompoundButton
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.work.WorkInfo
import androidx.work.WorkManager
import fr.free.nrw.commons.MapController.NearbyPlacesInfo
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.campaigns.CampaignView
import fr.free.nrw.commons.campaigns.CampaignsPresenter
import fr.free.nrw.commons.campaigns.ICampaignsView
import fr.free.nrw.commons.campaigns.models.Campaign
import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment
import fr.free.nrw.commons.databinding.FragmentContributionsBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.location.LocationUpdateListener
import fr.free.nrw.commons.media.MediaDetailPagerFragment
import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import fr.free.nrw.commons.nearby.NearbyController
import fr.free.nrw.commons.nearby.NearbyNotificationCardView
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment
import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself
import fr.free.nrw.commons.notification.NotificationController
import fr.free.nrw.commons.notification.models.Notification
import fr.free.nrw.commons.profile.ProfileActivity
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.UploadProgressActivity
import fr.free.nrw.commons.upload.worker.UploadWorker
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.LengthUtils.computeBearing
import fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween
import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished
import fr.free.nrw.commons.utils.PermissionUtils.hasPermission
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.util.Calendar
import java.util.Date
import javax.inject.Inject
import javax.inject.Named
class ContributionsFragment
: CommonsDaggerSupportFragment(), FragmentManager.OnBackStackChangedListener,
LocationUpdateListener, MediaDetailProvider, SensorEventListener, ICampaignsView,
ContributionsContract.View,
ContributionsListFragment.Callback {
@JvmField
@Inject
@Named("default_preferences")
var store: JsonKvStore? = null
@JvmField
@Inject
var nearbyController: NearbyController? = null
@JvmField
@Inject
var okHttpJsonApiClient: OkHttpJsonApiClient? = null
@JvmField
@Inject
var presenter: CampaignsPresenter? = null
@JvmField
@Inject
var locationManager: LocationServiceManager? = null
@JvmField
@Inject
var notificationController: NotificationController? = null
@JvmField
@Inject
var contributionController: ContributionController? = null
override var compositeDisposable: CompositeDisposable = CompositeDisposable()
private var contributionsListFragment: ContributionsListFragment? = null
// Getter for mediaDetailPagerFragment
var mediaDetailPagerFragment: MediaDetailPagerFragment? = null
private set
var binding: FragmentContributionsBinding? = null
@JvmField
@Inject
var contributionsPresenter: ContributionsPresenter? = null
@JvmField
@Inject
var sessionManager: SessionManager? = null
private var currentLatLng: LatLng? = null
private var isFragmentAttachedBefore = false
private var checkBoxView: View? = null
private var checkBox: CheckBox? = null
var notificationCount: TextView? = null
var pendingUploadsCountTextView: TextView? = null
var uploadsErrorTextView: TextView? = null
var pendingUploadsImageView: ImageView? = null
private var wlmCampaign: Campaign? = null
var userName: String? = null
private var isUserProfile = false
private var mSensorManager: SensorManager? = null
private var mLight: Sensor? = null
private var direction = 0f
private val nearbyLocationPermissionLauncher =
registerForActivityResult<Array<String>, Map<String, Boolean>>(
ActivityResultContracts.RequestMultiplePermissions(),
object : ActivityResultCallback<Map<String, Boolean>> {
override fun onActivityResult(result: Map<String, Boolean>) {
var areAllGranted = true
for (b in result.values) {
areAllGranted = areAllGranted && b
}
if (areAllGranted) {
onLocationPermissionGranted()
} else {
if (shouldShowRequestPermissionRationale(
permission.ACCESS_FINE_LOCATION
)
&& store!!.getBoolean("displayLocationPermissionForCardView", true)
&& !store!!.getBoolean("doNotAskForLocationPermission", false)
&& ((activity as MainActivity).activeFragment
== ActiveFragment.CONTRIBUTIONS)
) {
binding!!.cardViewNearby.permissionType =
NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION
} else {
displayYouWontSeeNearbyMessage()
}
}
}
})
private var shouldShowMediaDetailsFragment = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (arguments != null && requireArguments().getString(ProfileActivity.KEY_USERNAME) != null) {
userName = requireArguments().getString(ProfileActivity.KEY_USERNAME)
isUserProfile = true
}
mSensorManager = requireActivity().getSystemService(Context.SENSOR_SERVICE) as SensorManager
mLight = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ORIENTATION)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentContributionsBinding.inflate(inflater, container, false)
initWLMCampaign()
presenter!!.onAttachView(this)
contributionsPresenter!!.onAttachView(this)
binding!!.campaignsView.visibility = View.GONE
checkBoxView = View.inflate(activity, R.layout.nearby_permission_dialog, null)
checkBox = checkBoxView?.findViewById<View>(R.id.never_ask_again) as CheckBox
checkBox!!.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
if (isChecked) {
// Do not ask for permission on activity start again
store!!.putBoolean("displayLocationPermissionForCardView", false)
}
}
if (savedInstanceState != null) {
mediaDetailPagerFragment = childFragmentManager
.findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG) as MediaDetailPagerFragment?
contributionsListFragment = childFragmentManager
.findFragmentByTag(CONTRIBUTION_LIST_FRAGMENT_TAG) as ContributionsListFragment?
shouldShowMediaDetailsFragment = savedInstanceState.getBoolean("mediaDetailsVisible")
}
initFragments()
if (!isUserProfile) {
upDateUploadCount()
}
if (shouldShowMediaDetailsFragment) {
showMediaDetailPagerFragment()
} else {
if (mediaDetailPagerFragment != null) {
removeFragment(mediaDetailPagerFragment!!)
}
showContributionsListFragment()
}
if (!isBetaFlavour && sessionManager!!.isUserLoggedIn
&& sessionManager!!.currentAccount != null && !isUserProfile
) {
setUploadCount()
}
setHasOptionsMenu(true)
return binding!!.root
}
/**
* Initialise the campaign object for WML
*/
private fun initWLMCampaign() {
wlmCampaign = Campaign(
getString(R.string.wlm_campaign_title),
getString(R.string.wlm_campaign_description), Utils.getWLMStartDate().toString(),
Utils.getWLMEndDate().toString(), NearbyParentFragment.WLM_URL, true
)
}
override fun onCreateOptionsMenu(
menu: Menu,
inflater: MenuInflater
) {
// Removing contributions menu items for ProfileActivity
if (activity is ProfileActivity) {
return
}
inflater.inflate(R.menu.contribution_activity_notification_menu, menu)
val notificationsMenuItem = menu.findItem(R.id.notifications)
val notification = notificationsMenuItem.actionView
notificationCount = notification!!.findViewById(R.id.notification_count_badge)
val uploadMenuItem = menu.findItem(R.id.upload_tab)
val uploadMenuItemActionView = uploadMenuItem.actionView
pendingUploadsCountTextView = uploadMenuItemActionView!!.findViewById(
R.id.pending_uploads_count_badge
)
uploadsErrorTextView = uploadMenuItemActionView.findViewById(
R.id.uploads_error_count_badge
)
pendingUploadsImageView = uploadMenuItemActionView.findViewById(
R.id.pending_uploads_image_view
)
if (pendingUploadsImageView != null) {
pendingUploadsImageView!!.setOnClickListener { view: View? ->
startActivity(
Intent(
context,
UploadProgressActivity::class.java
)
)
}
}
if (pendingUploadsCountTextView != null) {
pendingUploadsCountTextView!!.setOnClickListener { view: View? ->
startActivity(
Intent(
context,
UploadProgressActivity::class.java
)
)
}
}
if (uploadsErrorTextView != null) {
uploadsErrorTextView!!.setOnClickListener { view: View? ->
startActivity(
Intent(
context,
UploadProgressActivity::class.java
)
)
}
}
notification.setOnClickListener { view: View? ->
startYourself(
context, "unread"
)
}
}
@SuppressLint("CheckResult")
fun setNotificationCount() {
compositeDisposable.add(
notificationController!!.getNotifications(false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ notificationList: List<Notification> ->
this.initNotificationViews(
notificationList
)
},
{ throwable: Throwable? ->
Timber.e(
throwable,
"Error occurred while loading notifications"
)
})
)
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Sets the visibility of the upload icon based on the number of failed and pending
* contributions.
*/
// public void setUploadIconVisibility() {
// contributionController.getFailedAndPendingContributions();
// contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(),
// list -> {
// updateUploadIcon(list.size());
// });
// }
/**
* Sets the count for the upload icon based on the number of pending and failed contributions.
*/
fun setUploadIconCount() {
contributionController!!.pendingContributions
contributionController!!.pendingContributionList!!.observe(
viewLifecycleOwner,
Observer<PagedList<Contribution>> { list: PagedList<Contribution> ->
updatePendingIcon(list.size)
})
contributionController!!.failedContributions
contributionController!!.failedContributionList!!.observe(
viewLifecycleOwner,
Observer<PagedList<Contribution>> { list: PagedList<Contribution> ->
updateErrorIcon(list.size)
})
}
fun scrollToTop() {
if (contributionsListFragment != null) {
contributionsListFragment!!.scrollToTop()
}
}
private fun initNotificationViews(notificationList: List<Notification>) {
Timber.d("Number of notifications is %d", notificationList.size)
if (notificationList.isEmpty()) {
notificationCount!!.visibility = View.GONE
} else {
notificationCount!!.visibility = View.VISIBLE
notificationCount!!.text = notificationList.size.toString()
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
/*
- There are some operations we need auth, so we need to make sure isAuthCookieAcquired.
- And since we use same retained fragment doesn't want to make all network operations
all over again on same fragment attached to recreated activity, we do this network
operations on first time fragment attached to an activity. Then they will be retained
until fragment life time ends.
*/
if (!isFragmentAttachedBefore && activity != null) {
isFragmentAttachedBefore = true
}
}
/**
* Replace FrameLayout with ContributionsListFragment, user will see contributions list. Creates
* new one if null.
*/
private fun showContributionsListFragment() {
// show nearby card view on contributions list is visible
if (binding!!.cardViewNearby != null && !isUserProfile) {
if (store!!.getBoolean("displayNearbyCardView", true)) {
if (binding!!.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY
) {
binding!!.cardViewNearby.visibility = View.VISIBLE
}
} else {
binding!!.cardViewNearby.visibility = View.GONE
}
}
showFragment(
contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment
)
}
private fun showMediaDetailPagerFragment() {
// hide nearby card view on media detail is visible
setupViewForMediaDetails()
showFragment(
mediaDetailPagerFragment!!, MEDIA_DETAIL_PAGER_FRAGMENT_TAG,
contributionsListFragment
)
}
private fun setupViewForMediaDetails() {
if (binding != null) {
binding!!.campaignsView.visibility = View.GONE
}
}
override fun onBackStackChanged() {
fetchCampaigns()
}
private fun initFragments() {
if (null == contributionsListFragment) {
contributionsListFragment = ContributionsListFragment()
val contributionsListBundle = Bundle()
contributionsListBundle.putString(ProfileActivity.KEY_USERNAME, userName)
contributionsListFragment!!.arguments = contributionsListBundle
}
if (shouldShowMediaDetailsFragment) {
showMediaDetailPagerFragment()
} else {
showContributionsListFragment()
}
showFragment(
contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment
)
}
/**
* Replaces the root frame layout with the given fragment
*
* @param fragment
* @param tag
* @param otherFragment
*/
private fun showFragment(fragment: Fragment, tag: String, otherFragment: Fragment?) {
val transaction = childFragmentManager.beginTransaction()
if (fragment.isAdded && otherFragment != null) {
transaction.hide(otherFragment)
transaction.show(fragment)
transaction.addToBackStack(tag)
transaction.commit()
childFragmentManager.executePendingTransactions()
} else if (fragment.isAdded && otherFragment == null) {
transaction.show(fragment)
transaction.addToBackStack(tag)
transaction.commit()
childFragmentManager.executePendingTransactions()
} else if (!fragment.isAdded && otherFragment != null) {
transaction.hide(otherFragment)
transaction.add(R.id.root_frame, fragment, tag)
transaction.addToBackStack(tag)
transaction.commit()
childFragmentManager.executePendingTransactions()
} else if (!fragment.isAdded) {
transaction.replace(R.id.root_frame, fragment, tag)
transaction.addToBackStack(tag)
transaction.commit()
childFragmentManager.executePendingTransactions()
}
}
fun removeFragment(fragment: Fragment) {
childFragmentManager
.beginTransaction()
.remove(fragment)
.commit()
childFragmentManager.executePendingTransactions()
}
private fun setUploadCount() {
okHttpJsonApiClient
?.getUploadCount((activity as MainActivity).sessionManager?.currentAccount!!.name)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())?.let {
compositeDisposable.add(
it
.subscribe(
{ uploadCount: Int -> this.displayUploadCount(uploadCount) },
{ t: Throwable? -> Timber.e(t, "Fetching upload count failed") }
))
}
}
private fun displayUploadCount(uploadCount: Int) {
if (requireActivity().isFinishing
|| resources == null
) {
return
}
(activity as MainActivity).setNumOfUploads(uploadCount)
}
override fun onPause() {
super.onPause()
locationManager!!.removeLocationListener(this)
locationManager!!.unregisterLocationManager()
mSensorManager!!.unregisterListener(this)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
}
override fun onResume() {
super.onResume()
contributionsPresenter!!.onAttachView(this)
locationManager!!.addLocationListener(this)
if (binding == null) {
return
}
binding!!.cardViewNearby.permissionRequestButton.setOnClickListener { v: View? ->
showNearbyCardPermissionRationale()
}
// Notification cards should only be seen on contributions list, not in media details
if (mediaDetailPagerFragment == null && !isUserProfile) {
if (store!!.getBoolean("displayNearbyCardView", true)) {
checkPermissionsAndShowNearbyCardView()
// Calling nearby card to keep showing it even when user clicks on it and comes back
try {
updateClosestNearbyCardViewInfo()
} catch (e: Exception) {
Timber.e(e)
}
if (binding!!.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY
) {
binding!!.cardViewNearby.visibility = View.VISIBLE
}
} else {
// Hide nearby notification card view if related shared preferences is false
binding!!.cardViewNearby.visibility = View.GONE
}
// Notification Count and Campaigns should not be set, if it is used in User Profile
if (!isUserProfile) {
setNotificationCount()
fetchCampaigns()
// Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// setUploadIconVisibility();
setUploadIconCount()
}
}
mSensorManager!!.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI)
}
private fun checkPermissionsAndShowNearbyCardView() {
if (hasPermission(
requireActivity(),
arrayOf(permission.ACCESS_FINE_LOCATION)
)
) {
onLocationPermissionGranted()
} else if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)
&& store!!.getBoolean("displayLocationPermissionForCardView", true)
&& !store!!.getBoolean("doNotAskForLocationPermission", false)
&& ((activity as MainActivity).activeFragment == ActiveFragment.CONTRIBUTIONS)
) {
binding!!.cardViewNearby.permissionType =
NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION
showNearbyCardPermissionRationale()
}
}
private fun requestLocationPermission() {
nearbyLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION))
}
private fun onLocationPermissionGranted() {
binding!!.cardViewNearby.permissionType =
NearbyNotificationCardView.PermissionType.NO_PERMISSION_NEEDED
locationManager!!.registerLocationManager()
}
private fun showNearbyCardPermissionRationale() {
showAlertDialog(
requireActivity(),
getString(R.string.nearby_card_permission_title),
getString(R.string.nearby_card_permission_explanation),
{ this.requestLocationPermission() },
{ this.displayYouWontSeeNearbyMessage() },
checkBoxView
)
}
private fun displayYouWontSeeNearbyMessage() {
showLongToast(
requireActivity(),
resources.getString(R.string.unable_to_display_nearest_place)
)
// Set to true as the user doesn't want the app to ask for location permission anymore
store!!.putBoolean("doNotAskForLocationPermission", true)
}
private fun updateClosestNearbyCardViewInfo() {
currentLatLng = locationManager!!.getLastLocation()
compositeDisposable.add(Observable.fromCallable {
nearbyController?.loadAttractionsFromLocation(
currentLatLng, currentLatLng, true,
false
)
} // thanks to boolean, it will only return closest result
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ nearbyPlacesInfo: NearbyPlacesInfo? ->
this.updateNearbyNotification(
nearbyPlacesInfo
)
},
{ throwable: Throwable? ->
Timber.d(throwable)
updateNearbyNotification(null)
})
)
}
private fun updateNearbyNotification(
nearbyPlacesInfo: NearbyPlacesInfo?
) {
if (nearbyPlacesInfo?.placeList != null && nearbyPlacesInfo.placeList.size > 0) {
var closestNearbyPlace: Place? = null
// Find the first nearby place that has no image and exists
for (place in nearbyPlacesInfo.placeList) {
if (place.pic == "" && place.exists) {
closestNearbyPlace = place
break
}
}
if (closestNearbyPlace == null) {
binding!!.cardViewNearby.visibility = View.GONE
} else {
val distance = formatDistanceBetween(currentLatLng, closestNearbyPlace.location)
closestNearbyPlace.setDistance(distance)
direction = computeBearing(currentLatLng!!, closestNearbyPlace.location).toFloat()
binding!!.cardViewNearby.updateContent(closestNearbyPlace)
}
} else {
// Means that no close nearby place is found
binding!!.cardViewNearby.visibility = View.GONE
}
// Prevent Nearby banner from appearing in Media Details, fixing bug https://github.com/commons-app/apps-android-commons/issues/4731
if (mediaDetailPagerFragment != null && !contributionsListFragment!!.isVisible) {
binding!!.cardViewNearby.visibility = View.GONE
}
}
override fun onDestroy() {
try {
compositeDisposable.clear()
childFragmentManager.removeOnBackStackChangedListener(this)
locationManager!!.unregisterLocationManager()
locationManager!!.removeLocationListener(this)
super.onDestroy()
} catch (exception: IllegalArgumentException) {
Timber.e(exception)
} catch (exception: IllegalStateException) {
Timber.e(exception)
}
}
override fun onLocationChangedSignificantly(latLng: LatLng) {
// Will be called if location changed more than 1000 meter
updateClosestNearbyCardViewInfo()
}
override fun onLocationChangedSlightly(latLng: LatLng) {
/* Update closest nearby notification card onLocationChangedSlightly
*/
try {
updateClosestNearbyCardViewInfo()
} catch (e: Exception) {
Timber.e(e)
}
}
override fun onLocationChangedMedium(latLng: LatLng) {
// Update closest nearby card view if location changed more than 500 meters
updateClosestNearbyCardViewInfo()
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
}
/**
* As the home screen has limited space, we have choosen to show either campaigns or WLM card.
* The WLM Card gets the priority over monuments, so if the WLM is going on we show that instead
* of campaigns on the campaigns card
*/
private fun fetchCampaigns() {
if (Utils.isMonumentsEnabled(Date())) {
if (binding != null) {
binding!!.campaignsView.setCampaign(wlmCampaign)
binding!!.campaignsView.visibility = View.VISIBLE
}
} else if (store!!.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) {
presenter!!.getCampaigns()
} else {
if (binding != null) {
binding!!.campaignsView.visibility = View.GONE
}
}
}
override fun showMessage(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
override fun showCampaigns(campaign: Campaign?) {
if (campaign != null && !isUserProfile) {
if (binding != null) {
binding!!.campaignsView.setCampaign(campaign)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
presenter!!.onDetachView()
}
override fun notifyDataSetChanged() {
if (mediaDetailPagerFragment != null) {
mediaDetailPagerFragment!!.notifyDataSetChanged()
}
}
/**
* Notify the viewpager that number of items have changed.
*/
override fun viewPagerNotifyDataSetChanged() {
if (mediaDetailPagerFragment != null) {
mediaDetailPagerFragment!!.notifyDataSetChanged()
}
}
/**
* Updates the visibility and text of the pending uploads count TextView based on the given
* count.
*
* @param pendingCount The number of pending uploads.
*/
fun updatePendingIcon(pendingCount: Int) {
if (pendingUploadsCountTextView != null) {
if (pendingCount != 0) {
pendingUploadsCountTextView!!.visibility = View.VISIBLE
pendingUploadsCountTextView!!.text = pendingCount.toString()
} else {
pendingUploadsCountTextView!!.visibility = View.INVISIBLE
}
}
}
/**
* Updates the visibility and text of the error uploads TextView based on the given count.
*
* @param errorCount The number of error uploads.
*/
fun updateErrorIcon(errorCount: Int) {
if (uploadsErrorTextView != null) {
if (errorCount != 0) {
uploadsErrorTextView!!.visibility = View.VISIBLE
uploadsErrorTextView!!.text = errorCount.toString()
} else {
uploadsErrorTextView!!.visibility = View.GONE
}
}
}
/**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* @param count The number of pending uploads.
*/
// public void updateUploadIcon(int count) {
// if (pendingUploadsImageView != null) {
// if (count != 0) {
// pendingUploadsImageView.setVisibility(View.VISIBLE);
// } else {
// pendingUploadsImageView.setVisibility(View.GONE);
// }
// }
// }
/**
* Replace whatever is in the current contributionsFragmentContainer view with
* mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects
* a contribution.
*/
override fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean) {
if (mediaDetailPagerFragment == null || !mediaDetailPagerFragment!!.isVisible) {
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true)
if (isUserProfile) {
(activity as ProfileActivity).setScroll(false)
}
showMediaDetailPagerFragment()
}
mediaDetailPagerFragment!!.showImage(position, isWikipediaButtonDisplayed)
}
override fun getMediaAtPosition(i: Int): Media? {
return contributionsListFragment!!.getMediaAtPosition(i)
}
override fun getTotalMediaCount(): Int {
return contributionsListFragment!!.totalMediaCount
}
override fun getContributionStateAt(position: Int): Int {
return contributionsListFragment!!.getContributionStateAt(position)
}
fun backButtonClicked(): Boolean {
if (mediaDetailPagerFragment != null && mediaDetailPagerFragment!!.isVisible) {
if (store!!.getBoolean("displayNearbyCardView", true) && !isUserProfile) {
if (binding!!.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY
) {
binding!!.cardViewNearby.visibility = View.VISIBLE
}
} else {
binding!!.cardViewNearby.visibility = View.GONE
}
removeFragment(mediaDetailPagerFragment!!)
showFragment(
contributionsListFragment!!, CONTRIBUTION_LIST_FRAGMENT_TAG,
mediaDetailPagerFragment
)
if (isUserProfile) {
// Fragment is associated with ProfileActivity
// Enable ParentViewPager Scroll
(activity as ProfileActivity).setScroll(true)
} else {
fetchCampaigns()
}
if (activity is MainActivity) {
// Fragment is associated with MainActivity
(activity as BaseActivity).supportActionBar
?.setDisplayHomeAsUpEnabled(false)
(activity as MainActivity).showTabs()
}
return true
}
return false
}
/**
* this function updates the number of contributions
*/
fun upDateUploadCount() {
WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkLiveData(UploadWorker::class.java.simpleName).observe(
viewLifecycleOwner
) { workInfos: List<WorkInfo?> ->
if (workInfos.size > 0) {
setUploadCount()
}
}
}
/**
* Restarts the upload process for a contribution
*
* @param contribution
*/
fun restartUpload(contribution: Contribution) {
contribution.dateUploadStarted = Calendar.getInstance().time
if (contribution.state == Contribution.STATE_FAILED) {
if (contribution.errorInfo == null) {
contribution.chunkInfo = null
contribution.transferred = 0
}
contributionsPresenter!!.checkDuplicateImageAndRestartContribution(contribution)
} else {
contribution.state = Contribution.STATE_QUEUED
contributionsPresenter!!.saveContribution(contribution)
Timber.d("Restarting for %s", contribution.toString())
}
}
/**
* Retry upload when it is failed
*
* @param contribution contribution to be retried
*/
fun retryUpload(contribution: Contribution) {
if (isInternetConnectionEstablished(context)) {
if (contribution.state == Contribution.STATE_PAUSED) {
restartUpload(contribution)
} else if (contribution.state == Contribution.STATE_FAILED) {
val retries = contribution.retries
// TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562
/* Limit the number of retries for a failed upload
to handle cases like invalid filename as such uploads
will never be successful */
if (retries < MAX_RETRIES) {
contribution.retries = retries + 1
Timber.d(
"Retried uploading %s %d times", contribution.media.filename,
retries + 1
)
restartUpload(contribution)
} else {
// TODO: Show the exact reason for failure
Toast.makeText(
context,
R.string.retry_limit_reached, Toast.LENGTH_SHORT
).show()
}
} else {
Timber.d("Skipping re-upload for non-failed %s", contribution.toString())
}
} else {
showLongToast(context, R.string.this_function_needs_network_connection)
}
}
/**
* Reload media detail fragment once media is nominated
*
* @param index item position that has been nominated
*/
override fun refreshNominatedMedia(index: Int) {
if (mediaDetailPagerFragment != null && !contributionsListFragment!!.isVisible) {
removeFragment(mediaDetailPagerFragment!!)
mediaDetailPagerFragment = MediaDetailPagerFragment.newInstance(false, true)
mediaDetailPagerFragment?.showImage(index)
showMediaDetailPagerFragment()
}
}
/**
* When the device rotates, rotate the Nearby banner's compass arrow in tandem.
*/
override fun onSensorChanged(event: SensorEvent) {
val rotateDegree = Math.round(event.values[0]).toFloat()
binding!!.cardViewNearby.rotateCompass(rotateDegree, direction)
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
// Nothing to do.
}
companion object {
private const val CONTRIBUTION_LIST_FRAGMENT_TAG = "ContributionListFragmentTag"
const val MEDIA_DETAIL_PAGER_FRAGMENT_TAG: String = "MediaDetailFragmentTag"
private const val MAX_RETRIES = 10
@JvmStatic
fun newInstance(): ContributionsFragment {
val fragment = ContributionsFragment()
fragment.retainInstance = true
return fragment
}
}
}

View file

@ -1,77 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.paging.PagedListAdapter;
import androidx.recyclerview.widget.DiffUtil;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.media.MediaClient;
/**
* Represents The View Adapter for the List of Contributions
*/
public class ContributionsListAdapter extends
PagedListAdapter<Contribution, ContributionViewHolder> {
private final Callback callback;
private final MediaClient mediaClient;
ContributionsListAdapter(final Callback callback,
final MediaClient mediaClient) {
super(DIFF_CALLBACK);
this.callback = callback;
this.mediaClient = mediaClient;
}
/**
* Uses DiffUtil to calculate the changes in the list
* It has methods that check ID and the content of the items to determine if its a new item
*/
private static final DiffUtil.ItemCallback<Contribution> DIFF_CALLBACK =
new DiffUtil.ItemCallback<Contribution>() {
@Override
public boolean areItemsTheSame(final Contribution oldContribution, final Contribution newContribution) {
return oldContribution.getPageId().equals(newContribution.getPageId());
}
@Override
public boolean areContentsTheSame(final Contribution oldContribution, final Contribution newContribution) {
return oldContribution.equals(newContribution);
}
};
/**
* Initializes the view holder with contribution data
*/
@Override
public void onBindViewHolder(@NonNull ContributionViewHolder holder, int position) {
holder.init(position, getItem(position));
}
Contribution getContributionForPosition(final int position) {
return getItem(position);
}
/**
* Creates the new View Holder which will be used to display items(contributions) using the
* onBindViewHolder(viewHolder,position)
*/
@NonNull
@Override
public ContributionViewHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int viewType) {
final ContributionViewHolder viewHolder = new ContributionViewHolder(
LayoutInflater.from(parent.getContext())
.inflate(R.layout.layout_contribution, parent, false),
callback, mediaClient);
return viewHolder;
}
public interface Callback {
void openMediaDetail(int contribution, boolean isWikipediaPageExists);
void addImageToWikipedia(Contribution contribution);
}
}

View file

@ -0,0 +1,72 @@
package fr.free.nrw.commons.contributions
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import fr.free.nrw.commons.R
import fr.free.nrw.commons.media.MediaClient
/**
* Represents The View Adapter for the List of Contributions
*/
class ContributionsListAdapter internal constructor(
private val callback: Callback,
private val mediaClient: MediaClient
) : PagedListAdapter<Contribution, ContributionViewHolder>(DIFF_CALLBACK) {
/**
* Initializes the view holder with contribution data
*/
override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) {
holder.init(position, getItem(position))
}
fun getContributionForPosition(position: Int): Contribution? {
return getItem(position)
}
/**
* Creates the new View Holder which will be used to display items(contributions) using the
* onBindViewHolder(viewHolder,position)
*/
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ContributionViewHolder {
val viewHolder = ContributionViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.layout_contribution, parent, false),
callback, mediaClient
)
return viewHolder
}
interface Callback {
fun openMediaDetail(contribution: Int, isWikipediaPageExists: Boolean)
fun addImageToWikipedia(contribution: Contribution?)
}
companion object {
/**
* Uses DiffUtil to calculate the changes in the list
* It has methods that check ID and the content of the items to determine if its a new item
*/
private val DIFF_CALLBACK: DiffUtil.ItemCallback<Contribution> =
object : DiffUtil.ItemCallback<Contribution>() {
override fun areItemsTheSame(
oldContribution: Contribution,
newContribution: Contribution
): Boolean {
return oldContribution.pageId == newContribution.pageId
}
override fun areContentsTheSame(
oldContribution: Contribution,
newContribution: Contribution
): Boolean {
return oldContribution == newContribution
}
}
}
}

View file

@ -1,25 +0,0 @@
package fr.free.nrw.commons.contributions;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import fr.free.nrw.commons.BasePresenter;
/**
* The contract for Contributions list View & Presenter
*/
public class ContributionsListContract {
public interface View {
void showWelcomeTip(boolean numberOfUploads);
void showProgress(boolean shouldShow);
void showNoContributionsUI(boolean shouldShow);
}
public interface UserActionListener extends BasePresenter<View> {
void refreshList(SwipeRefreshLayout swipeRefreshLayout);
}
}

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.contributions
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import fr.free.nrw.commons.BasePresenter
/**
* The contract for Contributions list View & Presenter
*/
class ContributionsListContract {
interface View {
fun showWelcomeTip(numberOfUploads: Boolean)
fun showProgress(shouldShow: Boolean)
fun showNoContributionsUI(shouldShow: Boolean)
}
interface UserActionListener : BasePresenter<View?> {
fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?)
}
}

View file

@ -1,534 +0,0 @@
package fr.free.nrw.commons.contributions;
import static android.view.View.GONE;
import static android.view.View.VISIBLE;
import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE;
import android.Manifest.permission;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import androidx.recyclerview.widget.SimpleItemAnimator;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback;
import fr.free.nrw.commons.databinding.FragmentContributionsListBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.media.MediaClient;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.Map;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import org.apache.commons.lang3.StringUtils;
import fr.free.nrw.commons.wikidata.model.WikiSite;
/**
* Created by root on 01.06.2018.
*/
public class ContributionsListFragment extends CommonsDaggerSupportFragment implements
ContributionsListContract.View, Callback,
WikipediaInstructionsDialogFragment.Callback {
private static final String RV_STATE = "rv_scroll_state";
@Inject
SystemThemeUtils systemThemeUtils;
@Inject
ContributionController controller;
@Inject
MediaClient mediaClient;
@Named(NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
@Inject
WikiSite languageWikipediaSite;
@Inject
ContributionsListPresenter contributionsListPresenter;
@Inject
SessionManager sessionManager;
private FragmentContributionsListBinding binding;
private Animation fab_close;
private Animation fab_open;
private Animation rotate_forward;
private Animation rotate_backward;
private boolean isFabOpen;
@VisibleForTesting
protected RecyclerView rvContributionsList;
@VisibleForTesting
protected ContributionsListAdapter adapter;
@Nullable
@VisibleForTesting
protected Callback callback;
private final int SPAN_COUNT_LANDSCAPE = 3;
private final int SPAN_COUNT_PORTRAIT = 1;
private int contributionsSize;
private String userName;
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> customSelectorLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(
new RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
boolean areAllGranted = true;
for (final boolean b : result.values()) {
areAllGranted = areAllGranted && b;
}
if (areAllGranted) {
controller.locationPermissionCallback.onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
controller.handleShowRationaleFlowCameraLocation(getActivity(),
inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
} else {
controller.locationPermissionCallback.onLocationPermissionDenied(
getActivity().getString(
R.string.in_app_camera_location_permission_denied));
}
}
}
});
@Override
public void onCreate(
@Nullable @org.jetbrains.annotations.Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Now that we are allowing this fragment to be started for
// any userName- we expect it to be passed as an argument
if (getArguments() != null) {
userName = getArguments().getString(ProfileActivity.KEY_USERNAME);
}
if (StringUtils.isEmpty(userName)) {
userName = sessionManager.getUserName();
}
}
@Override
public View onCreateView(
final LayoutInflater inflater, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentContributionsListBinding.inflate(
inflater, container, false
);
rvContributionsList = binding.contributionsList;
contributionsListPresenter.onAttachView(this);
binding.fabCustomGallery.setOnClickListener(v -> launchCustomSelector());
binding.fabCustomGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(), R.string.custom_selector_title);
return true;
});
if (Objects.equals(sessionManager.getUserName(), userName)) {
binding.tvContributionsOfUser.setVisibility(GONE);
binding.fabLayout.setVisibility(VISIBLE);
} else {
binding.tvContributionsOfUser.setVisibility(VISIBLE);
binding.tvContributionsOfUser.setText(
getString(R.string.contributions_of_user, userName));
binding.fabLayout.setVisibility(GONE);
}
initAdapter();
// pull down to refresh only enabled for self user.
if(Objects.equals(sessionManager.getUserName(), userName)){
binding.swipeRefreshLayout.setOnRefreshListener(() -> {
contributionsListPresenter.refreshList(binding.swipeRefreshLayout);
});
} else {
binding.swipeRefreshLayout.setEnabled(false);
}
return binding.getRoot();
}
@Override
public void onDestroyView() {
binding = null;
super.onDestroyView();
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if (getParentFragment() != null && getParentFragment() instanceof ContributionsFragment) {
callback = ((ContributionsFragment) getParentFragment());
}
}
@Override
public void onDetach() {
super.onDetach();
callback = null;//To avoid possible memory leak
}
private void initAdapter() {
adapter = new ContributionsListAdapter(this, mediaClient);
}
@Override
public void onViewCreated(final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initRecyclerView();
initializeAnimations();
setListeners();
}
private void initRecyclerView() {
final GridLayoutManager layoutManager = new GridLayoutManager(getContext(),
getSpanCount(getResources().getConfiguration().orientation));
rvContributionsList.setLayoutManager(layoutManager);
//Setting flicker animation of recycler view to false.
final ItemAnimator animator = rvContributionsList.getItemAnimator();
if (animator instanceof SimpleItemAnimator) {
((SimpleItemAnimator) animator).setSupportsChangeAnimations(false);
}
contributionsListPresenter.setup(userName,
Objects.equals(sessionManager.getUserName(), userName));
contributionsListPresenter.contributionList.observe(getViewLifecycleOwner(), list -> {
contributionsSize = list.size();
adapter.submitList(list);
if (callback != null) {
callback.notifyDataSetChanged();
}
});
rvContributionsList.setAdapter(adapter);
adapter.registerAdapterDataObserver(new AdapterDataObserver() {
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
super.onItemRangeInserted(positionStart, itemCount);
contributionsSize = adapter.getItemCount();
if (callback != null) {
callback.notifyDataSetChanged();
}
if (itemCount > 0 && positionStart == 0) {
if (adapter.getContributionForPosition(positionStart) != null) {
rvContributionsList
.scrollToPosition(0);//Newly upload items are always added to the top
}
}
}
/**
* Called whenever items in the list have changed
* Calls viewPagerNotifyDataSetChanged() that will notify the viewpager
*/
@Override
public void onItemRangeChanged(final int positionStart, final int itemCount) {
super.onItemRangeChanged(positionStart, itemCount);
if (callback != null) {
callback.viewPagerNotifyDataSetChanged();
}
}
});
//Fab close on touch outside (Scrolling or taping on item triggers this action).
rvContributionsList.addOnItemTouchListener(new OnItemTouchListener() {
/**
* Silently observe and/or take over touch events sent to the RecyclerView before
* they are handled by either the RecyclerView itself or its child views.
*/
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_DOWN) {
if (isFabOpen) {
animateFAB(isFabOpen);
}
}
return false;
}
/**
* Process a touch event as part of a gesture that was claimed by returning true
* from a previous call to {@link #onInterceptTouchEvent}.
*
* @param rv
* @param e MotionEvent describing the touch event. All coordinates are in the
* RecyclerView's coordinate system.
*/
@Override
public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
//required abstract method DO NOT DELETE
}
/**
* Called when a child of RecyclerView does not want RecyclerView and its ancestors
* to intercept touch events with {@link ViewGroup#onInterceptTouchEvent(MotionEvent)}.
*
* @param disallowIntercept True if the child does not want the parent to intercept
* touch events.
*/
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
//required abstract method DO NOT DELETE
}
});
}
private int getSpanCount(final int orientation) {
return orientation == Configuration.ORIENTATION_LANDSCAPE ?
SPAN_COUNT_LANDSCAPE : SPAN_COUNT_PORTRAIT;
}
@Override
public void onConfigurationChanged(final Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// check orientation
binding.fabLayout.setOrientation(
newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ?
LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);
rvContributionsList
.setLayoutManager(
new GridLayoutManager(getContext(), getSpanCount(newConfig.orientation)));
}
private void initializeAnimations() {
fab_open = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_open);
fab_close = AnimationUtils.loadAnimation(getActivity(), R.anim.fab_close);
rotate_forward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_forward);
rotate_backward = AnimationUtils.loadAnimation(getActivity(), R.anim.rotate_backward);
}
private void setListeners() {
binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen));
binding.fabCamera.setOnClickListener(view -> {
controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
animateFAB(isFabOpen);
});
binding.fabCamera.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(), R.string.add_contribution_from_camera);
return true;
});
binding.fabGallery.setOnClickListener(view -> {
controller.initiateGalleryPick(getActivity(), galleryPickLauncherForResult, true);
animateFAB(isFabOpen);
});
binding.fabGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(), R.string.menu_from_gallery);
return true;
});
}
/**
* Launch Custom Selector.
*/
protected void launchCustomSelector() {
controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult);
animateFAB(isFabOpen);
}
public void scrollToTop() {
rvContributionsList.smoothScrollToPosition(0);
}
private void animateFAB(final boolean isFabOpen) {
this.isFabOpen = !isFabOpen;
if (binding.fabPlus.isShown()) {
if (isFabOpen) {
binding.fabPlus.startAnimation(rotate_backward);
binding.fabCamera.startAnimation(fab_close);
binding.fabGallery.startAnimation(fab_close);
binding.fabCustomGallery.startAnimation(fab_close);
binding.fabCamera.hide();
binding.fabGallery.hide();
binding.fabCustomGallery.hide();
} else {
binding.fabPlus.startAnimation(rotate_forward);
binding.fabCamera.startAnimation(fab_open);
binding.fabGallery.startAnimation(fab_open);
binding.fabCustomGallery.startAnimation(fab_open);
binding.fabCamera.show();
binding.fabGallery.show();
binding.fabCustomGallery.show();
}
this.isFabOpen = !isFabOpen;
}
}
/**
* Shows welcome message if user has no contributions yet i.e. new user.
*/
@Override
public void showWelcomeTip(final boolean shouldShow) {
binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
}
/**
* Responsible to set progress bar invisible and visible
*
* @param shouldShow True when contributions list should be hidden.
*/
@Override
public void showProgress(final boolean shouldShow) {
binding.loadingContributionsProgressBar.setVisibility(shouldShow ? VISIBLE : GONE);
}
@Override
public void showNoContributionsUI(final boolean shouldShow) {
binding.noContributionsYet.setVisibility(shouldShow ? VISIBLE : GONE);
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
final GridLayoutManager layoutManager = (GridLayoutManager) rvContributionsList
.getLayoutManager();
outState.putParcelable(RV_STATE, layoutManager.onSaveInstanceState());
}
@Override
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (null != savedInstanceState) {
final Parcelable savedRecyclerLayoutState = savedInstanceState.getParcelable(RV_STATE);
rvContributionsList.getLayoutManager().onRestoreInstanceState(savedRecyclerLayoutState);
}
}
@Override
public void openMediaDetail(final int position, boolean isWikipediaButtonDisplayed) {
if (null != callback) {//Just being safe, ideally they won't be called when detached
callback.showDetail(position, isWikipediaButtonDisplayed);
}
}
/**
* Handle callback for wikipedia icon clicked
*
* @param contribution
*/
@Override
public void addImageToWikipedia(Contribution contribution) {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.add_picture_to_wikipedia_article_title),
getString(R.string.add_picture_to_wikipedia_article_desc),
() -> {
showAddImageToWikipediaInstructions(contribution);
}, () -> {
// do nothing
});
}
/**
* Display confirmation dialog with instructions when the user tries to add image to wikipedia
*
* @param contribution
*/
private void showAddImageToWikipediaInstructions(Contribution contribution) {
FragmentManager fragmentManager = getFragmentManager();
WikipediaInstructionsDialogFragment fragment = WikipediaInstructionsDialogFragment
.newInstance(contribution);
fragment.setCallback(this::onConfirmClicked);
fragment.show(fragmentManager, "WikimediaFragment");
}
public Media getMediaAtPosition(final int i) {
if (adapter.getContributionForPosition(i) != null) {
return adapter.getContributionForPosition(i).getMedia();
}
return null;
}
public int getTotalMediaCount() {
return contributionsSize;
}
/**
* Open the editor for the language Wikipedia
*
* @param contribution
*/
@Override
public void onConfirmClicked(@Nullable Contribution contribution, boolean copyWikicode) {
if (copyWikicode) {
String wikicode = contribution.getMedia().getWikiCode();
Utils.copy("wikicode", wikicode, getContext());
}
final String url =
languageWikipediaSite.mobileUrl() + "/wiki/" + contribution.getWikidataPlace()
.getWikipediaPageTitle();
Utils.handleWebUrl(getContext(), Uri.parse(url));
}
public Integer getContributionStateAt(int position) {
return adapter.getContributionForPosition(position).getState();
}
public interface Callback {
void notifyDataSetChanged();
void showDetail(int position, boolean isWikipediaButtonDisplayed);
// Notify the viewpager that number of items have changed.
void viewPagerNotifyDataSetChanged();
}
}

View file

@ -0,0 +1,551 @@
package fr.free.nrw.commons.contributions
import android.Manifest.permission
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.LinearLayout
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.VisibleForTesting
import androidx.paging.PagedList
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener
import androidx.recyclerview.widget.SimpleItemAnimator
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.WikipediaInstructionsDialogFragment.Companion.newInstance
import fr.free.nrw.commons.databinding.FragmentContributionsListBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.di.NetworkingModule
import fr.free.nrw.commons.filepicker.FilePicker
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.profile.ProfileActivity
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.SystemThemeUtils
import fr.free.nrw.commons.utils.ViewUtil.showShortToast
import fr.free.nrw.commons.wikidata.model.WikiSite
import org.apache.commons.lang3.StringUtils
import javax.inject.Inject
import javax.inject.Named
/**
* Created by root on 01.06.2018.
*/
class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsListContract.View,
ContributionsListAdapter.Callback, WikipediaInstructionsDialogFragment.Callback {
@JvmField
@Inject
var systemThemeUtils: SystemThemeUtils? = null
@JvmField
@Inject
var controller: ContributionController? = null
@JvmField
@Inject
var mediaClient: MediaClient? = null
@JvmField
@Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
@Inject
var languageWikipediaSite: WikiSite? = null
@JvmField
@Inject
var contributionsListPresenter: ContributionsListPresenter? = null
@JvmField
@Inject
var sessionManager: SessionManager? = null
private var binding: FragmentContributionsListBinding? = null
private var fab_close: Animation? = null
private var fab_open: Animation? = null
private var rotate_forward: Animation? = null
private var rotate_backward: Animation? = null
private var isFabOpen = false
private lateinit var inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>
@VisibleForTesting
var rvContributionsList: RecyclerView? = null
@VisibleForTesting
var adapter: ContributionsListAdapter? = null
@VisibleForTesting
var callback: Callback? = null
private val SPAN_COUNT_LANDSCAPE = 3
private val SPAN_COUNT_PORTRAIT = 1
private var contributionsSize = 0
private var userName: String? = null
private val galleryPickLauncherForResult = registerForActivityResult<Intent, ActivityResult>(
StartActivityForResult()
) { result: ActivityResult? ->
controller!!.handleActivityResultWithCallback(requireActivity()
) { callbacks: FilePicker.Callbacks? ->
controller!!.onPictureReturnedFromGallery(
result!!, requireActivity(), callbacks!!
)
}
}
private val customSelectorLauncherForResult = registerForActivityResult<Intent, ActivityResult>(
StartActivityForResult()
) { result: ActivityResult? ->
controller!!.handleActivityResultWithCallback(requireActivity()
) { callbacks: FilePicker.Callbacks? ->
controller!!.onPictureReturnedFromCustomSelector(
result!!, requireActivity(), callbacks!!
)
}
}
private val cameraPickLauncherForResult = registerForActivityResult<Intent, ActivityResult>(
StartActivityForResult()
) { result: ActivityResult? ->
controller!!.handleActivityResultWithCallback(requireActivity()
) { callbacks: FilePicker.Callbacks? ->
controller!!.onPictureReturnedFromCamera(
result!!, requireActivity(), callbacks!!
)
}
}
@SuppressLint("NewApi")
override fun onCreate(
savedInstanceState: Bundle?
) {
super.onCreate(savedInstanceState)
//Now that we are allowing this fragment to be started for
// any userName- we expect it to be passed as an argument
if (arguments != null) {
userName = requireArguments().getString(ProfileActivity.KEY_USERNAME)
}
if (StringUtils.isEmpty(userName)) {
userName = sessionManager!!.userName
}
inAppCameraLocationPermissionLauncher =
registerForActivityResult(RequestMultiplePermissions()) { result ->
val areAllGranted = result.values.all { it }
if (areAllGranted) {
controller?.locationPermissionCallback?.onLocationPermissionGranted()
} else {
activity?.let { currentActivity ->
if (currentActivity.shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
controller?.handleShowRationaleFlowCameraLocation(
currentActivity,
inAppCameraLocationPermissionLauncher, // Pass launcher
cameraPickLauncherForResult
)
} else {
controller?.locationPermissionCallback?.onLocationPermissionDenied(
currentActivity.getString(R.string.in_app_camera_location_permission_denied)
)
}
}
}
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentContributionsListBinding.inflate(
inflater, container, false
)
rvContributionsList = binding!!.contributionsList
contributionsListPresenter!!.onAttachView(this)
binding!!.fabCustomGallery.setOnClickListener { v: View? -> launchCustomSelector() }
binding!!.fabCustomGallery.setOnLongClickListener { view: View? ->
showShortToast(context, fr.free.nrw.commons.R.string.custom_selector_title)
true
}
if (sessionManager!!.userName == userName) {
binding!!.tvContributionsOfUser.visibility = View.GONE
binding!!.fabLayout.visibility = View.VISIBLE
} else {
binding!!.tvContributionsOfUser.visibility = View.VISIBLE
binding!!.tvContributionsOfUser.text =
getString(fr.free.nrw.commons.R.string.contributions_of_user, userName)
binding!!.fabLayout.visibility = View.GONE
}
initAdapter()
// pull down to refresh only enabled for self user.
if (sessionManager!!.userName == userName) {
binding!!.swipeRefreshLayout.setOnRefreshListener {
contributionsListPresenter!!.refreshList(
binding!!.swipeRefreshLayout
)
}
} else {
binding!!.swipeRefreshLayout.isEnabled = false
}
return binding!!.root
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
override fun onAttach(context: Context) {
super.onAttach(context)
if (parentFragment != null && parentFragment is ContributionsFragment) {
callback = (parentFragment as ContributionsFragment)
}
}
override fun onDetach() {
super.onDetach()
callback = null //To avoid possible memory leak
}
private fun initAdapter() {
adapter = ContributionsListAdapter(this, mediaClient!!)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initRecyclerView()
initializeAnimations()
setListeners()
}
private fun initRecyclerView() {
val layoutManager = GridLayoutManager(
context,
getSpanCount(resources.configuration.orientation)
)
rvContributionsList!!.layoutManager = layoutManager
//Setting flicker animation of recycler view to false.
val animator = rvContributionsList!!.itemAnimator
if (animator is SimpleItemAnimator) {
animator.supportsChangeAnimations = false
}
contributionsListPresenter!!.setup(
userName,
sessionManager!!.userName == userName
)
contributionsListPresenter!!.contributionList?.observe(
viewLifecycleOwner
) { list: PagedList<Contribution>? ->
if (list != null) {
contributionsSize = list.size
}
adapter!!.submitList(list)
if (callback != null) {
callback!!.notifyDataSetChanged()
}
}
rvContributionsList!!.adapter = adapter
adapter!!.registerAdapterDataObserver(object : AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
contributionsSize = adapter!!.itemCount
if (callback != null) {
callback!!.notifyDataSetChanged()
}
if (itemCount > 0 && positionStart == 0) {
if (adapter!!.getContributionForPosition(positionStart) != null) {
rvContributionsList!!
.scrollToPosition(0) //Newly upload items are always added to the top
}
}
}
/**
* Called whenever items in the list have changed
* Calls viewPagerNotifyDataSetChanged() that will notify the viewpager
*/
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
super.onItemRangeChanged(positionStart, itemCount)
if (callback != null) {
callback!!.viewPagerNotifyDataSetChanged()
}
}
})
//Fab close on touch outside (Scrolling or taping on item triggers this action).
rvContributionsList!!.addOnItemTouchListener(object : OnItemTouchListener {
/**
* Silently observe and/or take over touch events sent to the RecyclerView before
* they are handled by either the RecyclerView itself or its child views.
*/
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.action == MotionEvent.ACTION_DOWN) {
if (isFabOpen) {
animateFAB(isFabOpen)
}
}
return false
}
/**
* Process a touch event as part of a gesture that was claimed by returning true
* from a previous call to [.onInterceptTouchEvent].
*
* @param rv
* @param e MotionEvent describing the touch event. All coordinates are in the
* RecyclerView's coordinate system.
*/
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
//required abstract method DO NOT DELETE
}
/**
* Called when a child of RecyclerView does not want RecyclerView and its ancestors
* to intercept touch events with [ViewGroup.onInterceptTouchEvent].
*
* @param disallowIntercept True if the child does not want the parent to intercept
* touch events.
*/
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
//required abstract method DO NOT DELETE
}
})
}
private fun getSpanCount(orientation: Int): Int {
return if (orientation == Configuration.ORIENTATION_LANDSCAPE) SPAN_COUNT_LANDSCAPE else SPAN_COUNT_PORTRAIT
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// check orientation
binding!!.fabLayout.orientation =
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
rvContributionsList
?.setLayoutManager(
GridLayoutManager(context, getSpanCount(newConfig.orientation))
)
}
private fun initializeAnimations() {
fab_open = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.fab_open)
fab_close = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.fab_close)
rotate_forward = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.rotate_forward)
rotate_backward = AnimationUtils.loadAnimation(activity, fr.free.nrw.commons.R.anim.rotate_backward)
}
private fun setListeners() {
binding!!.fabPlus.setOnClickListener { view: View? -> animateFAB(isFabOpen) }
binding!!.fabCamera.setOnClickListener { view: View? ->
controller!!.initiateCameraPick(
requireActivity(),
inAppCameraLocationPermissionLauncher,
cameraPickLauncherForResult
)
animateFAB(isFabOpen)
}
binding!!.fabCamera.setOnLongClickListener { view: View? ->
showShortToast(
context,
fr.free.nrw.commons.R.string.add_contribution_from_camera
)
true
}
binding!!.fabGallery.setOnClickListener { view: View? ->
controller!!.initiateGalleryPick(requireActivity(), galleryPickLauncherForResult, true)
animateFAB(isFabOpen)
}
binding!!.fabGallery.setOnLongClickListener { view: View? ->
showShortToast(context, fr.free.nrw.commons.R.string.menu_from_gallery)
true
}
}
/**
* Launch Custom Selector.
*/
protected fun launchCustomSelector() {
controller!!.initiateCustomGalleryPickWithPermission(
requireActivity(),
customSelectorLauncherForResult
)
animateFAB(isFabOpen)
}
fun scrollToTop() {
rvContributionsList!!.smoothScrollToPosition(0)
}
private fun animateFAB(isFabOpen: Boolean) {
this.isFabOpen = !isFabOpen
if (binding!!.fabPlus.isShown) {
if (isFabOpen) {
binding!!.fabPlus.startAnimation(rotate_backward)
binding!!.fabCamera.startAnimation(fab_close)
binding!!.fabGallery.startAnimation(fab_close)
binding!!.fabCustomGallery.startAnimation(fab_close)
binding!!.fabCamera.hide()
binding!!.fabGallery.hide()
binding!!.fabCustomGallery.hide()
} else {
binding!!.fabPlus.startAnimation(rotate_forward)
binding!!.fabCamera.startAnimation(fab_open)
binding!!.fabGallery.startAnimation(fab_open)
binding!!.fabCustomGallery.startAnimation(fab_open)
binding!!.fabCamera.show()
binding!!.fabGallery.show()
binding!!.fabCustomGallery.show()
}
this.isFabOpen = !isFabOpen
}
}
/**
* Shows welcome message if user has no contributions yet i.e. new user.
*/
override fun showWelcomeTip(shouldShow: Boolean) {
binding!!.noContributionsYet.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
/**
* Responsible to set progress bar invisible and visible
*
* @param shouldShow True when contributions list should be hidden.
*/
override fun showProgress(shouldShow: Boolean) {
binding!!.loadingContributionsProgressBar.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
override fun showNoContributionsUI(shouldShow: Boolean) {
binding!!.noContributionsYet.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val layoutManager = rvContributionsList
?.getLayoutManager() as GridLayoutManager?
outState.putParcelable(RV_STATE, layoutManager!!.onSaveInstanceState())
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
if (null != savedInstanceState) {
val savedRecyclerLayoutState = savedInstanceState.getParcelable<Parcelable>(RV_STATE)
rvContributionsList!!.layoutManager!!.onRestoreInstanceState(savedRecyclerLayoutState)
}
}
override fun openMediaDetail(position: Int, isWikipediaButtonDisplayed: Boolean) {
if (null != callback) { //Just being safe, ideally they won't be called when detached
callback!!.showDetail(position, isWikipediaButtonDisplayed)
}
}
/**
* Handle callback for wikipedia icon clicked
*
* @param contribution
*/
override fun addImageToWikipedia(contribution: Contribution?) {
showAlertDialog(
requireActivity(),
getString(fr.free.nrw.commons.R.string.add_picture_to_wikipedia_article_title),
getString(fr.free.nrw.commons.R.string.add_picture_to_wikipedia_article_desc),
{
if (contribution != null) {
showAddImageToWikipediaInstructions(contribution)
}
}, {})
}
/**
* Display confirmation dialog with instructions when the user tries to add image to wikipedia
*
* @param contribution
*/
private fun showAddImageToWikipediaInstructions(contribution: Contribution) {
val fragmentManager = fragmentManager
val fragment = newInstance(contribution)
fragment.callback =
WikipediaInstructionsDialogFragment.Callback { contribution: Contribution?, copyWikicode: Boolean ->
this.onConfirmClicked(
contribution,
copyWikicode
)
}
fragment.show(fragmentManager!!, "WikimediaFragment")
}
fun getMediaAtPosition(i: Int): Media? {
if (adapter!!.getContributionForPosition(i) != null) {
return adapter!!.getContributionForPosition(i)!!.media
}
return null
}
val totalMediaCount: Int
get() = contributionsSize
/**
* Open the editor for the language Wikipedia
*
* @param contribution
*/
override fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) {
if (copyWikicode) {
val wikicode = contribution!!.media.wikiCode
Utils.copy("wikicode", wikicode, context)
}
val url =
languageWikipediaSite!!.mobileUrl() + "/wiki/" + (contribution!!.wikidataPlace
?.getWikipediaPageTitle())
Utils.handleWebUrl(context, Uri.parse(url))
}
fun getContributionStateAt(position: Int): Int {
return adapter!!.getContributionForPosition(position)!!.state
}
interface Callback {
fun notifyDataSetChanged()
fun showDetail(position: Int, isWikipediaButtonDisplayed: Boolean)
// Notify the viewpager that number of items have changed.
fun viewPagerNotifyDataSetChanged()
}
companion object {
private const val RV_STATE = "rv_scroll_state"
}
}

View file

@ -1,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<PagedList<Contribution>> contributionList;
private MutableLiveData<List<Contribution>> liveData = new MutableLiveData<>();
private List<Contribution> 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<Integer, Contribution> factory;
boolean shouldSetBoundaryCallback;
if (!isSelf) {
//We don't want to persist contributions for other user's, therefore
// creating a new DataSource for them
contributionsRemoteDataSource.setUserName(userName);
factory = new Factory<Integer, Contribution>() {
@NonNull
@Override
public DataSource<Integer, Contribution> create() {
return contributionsRemoteDataSource;
}
};
shouldSetBoundaryCallback = false;
} else {
contributionBoundaryCallback.setUserName(userName);
shouldSetBoundaryCallback = true;
factory = repository.fetchContributionsWithStates(
Collections.singletonList(Contribution.STATE_COMPLETED));
}
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
if (shouldSetBoundaryCallback) {
livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback);
}
contributionList = livePagedListBuilder.build();
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;
});
}
}

View file

@ -0,0 +1,91 @@
package fr.free.nrw.commons.contributions
import androidx.lifecycle.LiveData
import androidx.paging.DataSource
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import fr.free.nrw.commons.di.CommonsApplicationModule
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import javax.inject.Inject
import javax.inject.Named
/**
* The presenter class for Contributions
*/
class ContributionsListPresenter @Inject internal constructor(
private val contributionBoundaryCallback: ContributionBoundaryCallback,
private val contributionsRemoteDataSource: ContributionsRemoteDataSource,
private val repository: ContributionsRepository,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler
) : ContributionsListContract.UserActionListener {
private val compositeDisposable = CompositeDisposable()
var contributionList: LiveData<PagedList<Contribution>>? = null
override fun onAttachView(view: ContributionsListContract.View) {
}
/**
* Setup the paged list. This method sets the configuration for paged list and ties it up with
* the live data object. This method can be tweaked to update the lazy loading behavior of the
* contributions list
*/
fun setup(userName: String?, isSelf: Boolean) {
val pagedListConfig =
(PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build()
val factory: DataSource.Factory<Int, Contribution>
val shouldSetBoundaryCallback: Boolean
if (!isSelf) {
//We don't want to persist contributions for other user's, therefore
// creating a new DataSource for them
contributionsRemoteDataSource.userName = userName
factory = object : DataSource.Factory<Int, Contribution>() {
override fun create(): DataSource<Int, Contribution> {
return contributionsRemoteDataSource
}
}
shouldSetBoundaryCallback = false
} else {
contributionBoundaryCallback.userName = userName
shouldSetBoundaryCallback = true
factory = repository.fetchContributionsWithStates(
listOf(Contribution.STATE_COMPLETED)
)
}
val livePagedListBuilder: LivePagedListBuilder<Int, Contribution> = LivePagedListBuilder(
factory,
pagedListConfig
)
if (shouldSetBoundaryCallback) {
livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback)
}
contributionList = livePagedListBuilder.build()
}
override fun onDetachView() {
compositeDisposable.clear()
contributionsRemoteDataSource.dispose()
contributionBoundaryCallback.dispose()
}
/**
* It is used to refresh list.
*
* @param swipeRefreshLayout used to stop refresh animation when
* refresh finishes.
*/
override fun refreshList(swipeRefreshLayout: SwipeRefreshLayout?) {
contributionBoundaryCallback.refreshList {
if (swipeRefreshLayout != null) {
swipeRefreshLayout.isRefreshing = false
}
Unit
}
}
}

View file

@ -1,131 +0,0 @@
package fr.free.nrw.commons.contributions;
import androidx.paging.DataSource.Factory;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
/**
* The LocalDataSource class for Contributions
*/
class ContributionsLocalDataSource {
private final ContributionDao contributionDao;
private final JsonKvStore defaultKVStore;
@Inject
public ContributionsLocalDataSource(
@Named("default_preferences") final JsonKvStore defaultKVStore,
final ContributionDao contributionDao) {
this.defaultKVStore = defaultKVStore;
this.contributionDao = contributionDao;
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public String getString(final String key) {
return defaultKVStore.getString(key);
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public long getLong(final String key) {
return defaultKVStore.getLong(key);
}
/**
* Get contribution object from cursor
*
* @param uri
* @return
*/
public Contribution getContributionWithFileName(final String uri) {
final List<Contribution> contributionWithUri = contributionDao.getContributionWithTitle(
uri);
if (!contributionWithUri.isEmpty()) {
return contributionWithUri.get(0);
}
return null;
}
/**
* Remove a contribution from the contributions table
*
* @param contribution
* @return
*/
public Completable deleteContribution(final Contribution contribution) {
return contributionDao.delete(contribution);
}
/**
* Deletes contributions with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsWithStates(List<Integer> states) {
return contributionDao.deleteContributionsWithStates(states);
}
public Factory<Integer, Contribution> getContributions() {
return contributionDao.fetchContributions();
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
public Factory<Integer, Contribution> getContributionsWithStates(List<Integer> states) {
return contributionDao.getContributions(states);
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
public Factory<Integer, Contribution> getContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return contributionDao.getContributionsSortedByDateUploadStarted(states);
}
public Single<List<Long>> saveContributions(final List<Contribution> contributions) {
final List<Contribution> contributionList = new ArrayList<>();
for (final Contribution contribution : contributions) {
final Contribution oldContribution = contributionDao.getContribution(
contribution.getPageId());
if (oldContribution != null) {
contribution.setWikidataPlace(oldContribution.getWikidataPlace());
}
contributionList.add(contribution);
}
return contributionDao.save(contributionList);
}
public Completable saveContributions(Contribution contribution) {
return contributionDao.save(contribution);
}
public void set(final String key, final long value) {
defaultKVStore.putLong(key, value);
}
public Completable updateContribution(final Contribution contribution) {
return contributionDao.update(contribution);
}
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return contributionDao.updateContributionsWithStates(states, newState);
}
}

View file

@ -0,0 +1,121 @@
package fr.free.nrw.commons.contributions
import androidx.paging.DataSource
import fr.free.nrw.commons.kvstore.JsonKvStore
import io.reactivex.Completable
import io.reactivex.Single
import javax.inject.Inject
import javax.inject.Named
/**
* The LocalDataSource class for Contributions
*/
class ContributionsLocalDataSource @Inject constructor(
@param:Named("default_preferences") private val defaultKVStore: JsonKvStore,
private val contributionDao: ContributionDao
) {
/**
* Fetch default number of contributions to be show, based on user preferences
*/
fun getString(key: String): String? {
return defaultKVStore.getString(key)
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
fun getLong(key: String): Long {
return defaultKVStore.getLong(key)
}
/**
* Get contribution object from cursor
*
* @param uri
* @return
*/
fun getContributionWithFileName(uri: String): Contribution {
val contributionWithUri = contributionDao.getContributionWithTitle(uri)
if (contributionWithUri.isNotEmpty()) {
return contributionWithUri[0]
}
throw IllegalArgumentException("Contribution not found for URI: $uri")
}
/**
* Remove a contribution from the contributions table
*
* @param contribution
* @return
*/
fun deleteContribution(contribution: Contribution): Completable {
return contributionDao.delete(contribution)
}
/**
* Deletes contributions with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
fun deleteContributionsWithStates(states: List<Int>): Completable {
return contributionDao.deleteContributionsWithStates(states)
}
fun getContributions(): DataSource.Factory<Int, Contribution> {
return contributionDao.fetchContributions()
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
fun getContributionsWithStates(states: List<Int>): DataSource.Factory<Int, Contribution> {
return contributionDao.getContributions(states)
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
fun getContributionsWithStatesSortedByDateUploadStarted(
states: List<Int>
): DataSource.Factory<Int, Contribution> {
return contributionDao.getContributionsSortedByDateUploadStarted(states)
}
fun saveContributions(contributions: List<Contribution>): Single<List<Long>> {
val contributionList: MutableList<Contribution> = ArrayList()
for (contribution in contributions) {
val oldContribution = contributionDao.getContribution(
contribution.pageId
)
if (oldContribution != null) {
contribution.wikidataPlace = oldContribution.wikidataPlace
}
contributionList.add(contribution)
}
return contributionDao.save(contributionList)
}
fun saveContributions(contribution: Contribution): Completable {
return contributionDao.save(contribution)
}
fun set(key: String, value: Long) {
defaultKVStore.putLong(key, value)
}
fun updateContribution(contribution: Contribution): Completable {
return contributionDao.update(contribution)
}
fun updateContributionsWithStates(states: List<Int>, newState: Int): Completable {
return contributionDao.updateContributionsWithStates(states, newState)
}
}

View file

@ -1,15 +0,0 @@
package fr.free.nrw.commons.contributions;
import dagger.Binds;
import dagger.Module;
/**
* The Dagger Module for contributions related presenters and (some other objects maybe in future)
*/
@Module
public abstract class ContributionsModule {
@Binds
public abstract ContributionsContract.UserActionListener bindsContibutionsPresenter(
ContributionsPresenter presenter);
}

View file

@ -0,0 +1,16 @@
package fr.free.nrw.commons.contributions
import dagger.Binds
import dagger.Module
/**
* The Dagger Module for contributions-related presenters and other dependencies
*/
@Module
abstract class ContributionsModule {
@Binds
abstract fun bindsContributionsPresenter(
presenter: ContributionsPresenter?
): ContributionsContract.UserActionListener?
}

View file

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

View file

@ -0,0 +1,88 @@
package fr.free.nrw.commons.contributions
import androidx.work.ExistingWorkPolicy
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest
import fr.free.nrw.commons.utils.ImageUtils
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
/**
* The presenter class for Contributions
*/
class ContributionsPresenter @Inject internal constructor(
private val contributionsRepository: ContributionsRepository,
private val uploadRepository: UploadRepository,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler
) : ContributionsContract.UserActionListener {
private var compositeDisposable: CompositeDisposable? = null
private var view: ContributionsContract.View? = null
@JvmField
@Inject
var mediaDataExtractor: MediaDataExtractor? = null
override fun onAttachView(view: ContributionsContract.View) {
this.view = view
compositeDisposable = CompositeDisposable()
}
override fun onDetachView() {
this.view = null
compositeDisposable!!.clear()
}
override fun getContributionsWithTitle(title: String): Contribution {
return contributionsRepository.getContributionWithFileName(title)
}
/**
* Checks if a contribution is a duplicate and restarts the contribution process if it is not.
*
* @param contribution The contribution to check and potentially restart.
*/
fun checkDuplicateImageAndRestartContribution(contribution: Contribution) {
compositeDisposable!!.add(
uploadRepository
.checkDuplicateImage(
contribution.contentUri,
contribution.localUri)
.subscribeOn(ioThreadScheduler)
.subscribe { imageCheckResult: Int ->
if (imageCheckResult == ImageUtils.IMAGE_OK) {
contribution.state = Contribution.STATE_QUEUED
saveContribution(contribution)
} else {
Timber.e("Contribution already exists")
compositeDisposable!!.add(
contributionsRepository
.deleteContributionFromDB(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe()
)
}
})
}
/**
* Update the contribution's state in the databse, upon completion, trigger the workmanager to
* process this contribution
*
* @param contribution
*/
fun saveContribution(contribution: Contribution) {
compositeDisposable!!.add(contributionsRepository
.save(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe {
makeOneTimeWorkRequest(
view!!.getContext().applicationContext, ExistingWorkPolicy.KEEP
)
})
}
}

View file

@ -0,0 +1,28 @@
package fr.free.nrw.commons.contributions
import dagger.Module
import dagger.Provides
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.wikidata.model.WikiSite
import javax.inject.Named
/**
* The Dagger Module for contributions-related providers
*/
@Module
class ContributionsProvidesModule {
@Provides
fun providesApplicationKvStore(
@Named("default_preferences") kvStore: JsonKvStore
): JsonKvStore {
return kvStore
}
@Provides
fun providesLanguageWikipediaSite(
@Named("language-wikipedia-wikisite") languageWikipediaSite: WikiSite
): WikiSite {
return languageWikipediaSite
}
}

View file

@ -1,112 +0,0 @@
package fr.free.nrw.commons.contributions;
import androidx.paging.DataSource.Factory;
import io.reactivex.Completable;
import java.util.List;
import javax.inject.Inject;
import io.reactivex.Single;
/**
* The repository class for contributions
*/
public class ContributionsRepository {
private ContributionsLocalDataSource localDataSource;
@Inject
public ContributionsRepository(ContributionsLocalDataSource localDataSource) {
this.localDataSource = localDataSource;
}
/**
* Fetch default number of contributions to be show, based on user preferences
*/
public String getString(String key) {
return localDataSource.getString(key);
}
/**
* Deletes a failed upload from DB
*
* @param contribution
* @return
*/
public Completable deleteContributionFromDB(Contribution contribution) {
return localDataSource.deleteContribution(contribution);
}
/**
* Deletes contributions from the database with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsFromDBWithStates(List<Integer> states) {
return localDataSource.deleteContributionsWithStates(states);
}
/**
* Get contribution object with title
*
* @param fileName
* @return
*/
public Contribution getContributionWithFileName(String fileName) {
return localDataSource.getContributionWithFileName(fileName);
}
public Factory<Integer, Contribution> fetchContributions() {
return localDataSource.getContributions();
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
public Factory<Integer, Contribution> fetchContributionsWithStates(List<Integer> states) {
return localDataSource.getContributionsWithStates(states);
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
public Factory<Integer, Contribution> fetchContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states);
}
public Single<List<Long>> save(List<Contribution> contributions) {
return localDataSource.saveContributions(contributions);
}
public Completable save(Contribution contributions) {
return localDataSource.saveContributions(contributions);
}
public void set(String key, long value) {
localDataSource.set(key, value);
}
public Completable updateContribution(Contribution contribution) {
return localDataSource.updateContribution(contribution);
}
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return localDataSource.updateContributionsWithStates(states, newState);
}
}

View file

@ -0,0 +1,102 @@
package fr.free.nrw.commons.contributions
import androidx.paging.DataSource
import io.reactivex.Completable
import io.reactivex.Single
import javax.inject.Inject
/**
* The repository class for contributions
*/
class ContributionsRepository @Inject constructor(private val localDataSource: ContributionsLocalDataSource) {
/**
* Fetch default number of contributions to be show, based on user preferences
*/
fun getString(key: String): String? {
return localDataSource.getString(key)
}
/**
* Deletes a failed upload from DB
*
* @param contribution
* @return
*/
fun deleteContributionFromDB(contribution: Contribution): Completable {
return localDataSource.deleteContribution(contribution)
}
/**
* Deletes contributions from the database with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
fun deleteContributionsFromDBWithStates(states: List<Int>): Completable {
return localDataSource.deleteContributionsWithStates(states)
}
/**
* Get contribution object with title
*
* @param fileName
* @return
*/
fun getContributionWithFileName(fileName: String): Contribution {
return localDataSource.getContributionWithFileName(fileName)
}
fun fetchContributions(): DataSource.Factory<Int, Contribution> {
return localDataSource.getContributions()
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
fun fetchContributionsWithStates(states: List<Int>): DataSource.Factory<Int, Contribution> {
return localDataSource.getContributionsWithStates(states)
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
fun fetchContributionsWithStatesSortedByDateUploadStarted(
states: List<Int>
): DataSource.Factory<Int, Contribution> {
return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states)
}
fun save(contributions: List<Contribution>): Single<List<Long>> {
return localDataSource.saveContributions(contributions)
}
fun save(contributions: Contribution): Completable {
return localDataSource.saveContributions(contributions)
}
operator fun set(key: String, value: Long) {
localDataSource.set(key, value)
}
fun updateContribution(contribution: Contribution): Completable {
return localDataSource.updateContribution(contribution)
}
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
fun updateContributionsWithStates(states: List<Int>, newState: Int): Completable {
return localDataSource.updateContributionsWithStates(states, newState)
}
}

View file

@ -1,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.
* <p>
* When the app is terminated or the device is restarted, contributions remain in the
* 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So,
* retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the
* list of uploads that appear as stuck on opening the app again
*/
@SuppressLint("CheckResult")
private void checkAndResumeStuckUploads() {
List<Contribution> stuckUploads = contributionDao.getContribution(
Collections.singletonList(Contribution.STATE_IN_PROGRESS))
.subscribeOn(Schedulers.io())
.blockingGet();
Timber.d("Resuming " + stuckUploads.size() + " uploads...");
if (!stuckUploads.isEmpty()) {
for (Contribution contribution : stuckUploads) {
contribution.setState(Contribution.STATE_QUEUED);
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
Completable.fromAction(() -> contributionDao.saveSynchronous(contribution))
.subscribeOn(Schedulers.io())
.subscribe();
}
WorkRequestHelper.Companion.makeOneTimeWorkRequest(
this, ExistingWorkPolicy.APPEND_OR_REPLACE);
}
}
@Override
protected void onPostCreate(@Nullable Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
//quizChecker.initQuizCheck(this);
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt("viewPagerCurrentItem", binding.pager.getCurrentItem());
outState.putString("activeFragment", activeFragment.name());
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
String activeFragmentName = savedInstanceState.getString("activeFragment");
if (activeFragmentName != null) {
restoreActiveFragment(activeFragmentName);
}
}
private void restoreActiveFragment(@NonNull String fragmentName) {
if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) {
setTitle(getString(R.string.contributions_fragment));
loadFragment(ContributionsFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.NEARBY.name())) {
setTitle(getString(R.string.nearby_fragment));
loadFragment(NearbyParentFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) {
setTitle(getString(R.string.navigation_item_explore));
loadFragment(ExploreFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) {
setTitle(getString(R.string.bookmarks));
loadFragment(BookmarkFragment.newInstance(), false);
}
}
@Override
public void onBackPressed() {
if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) {
// Means that contribution fragment is visible
if (!contributionsFragment.backButtonClicked()) {//If this one does not wan't to handle
// the back press, let the activity do so
super.onBackPressed();
}
} else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) {
// Means that nearby fragment is visible
/* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is
not expanded. So if the back button is pressed, then go back to the Contributions tab */
if (!nearbyParentFragment.backButtonClicked()) {
getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment)
.commit();
setSelectedItemId(NavTab.CONTRIBUTIONS.code());
}
} else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) {
// Means that explore fragment is visible
if (!exploreFragment.onBackPressed()) {
if (applicationKvStore.getBoolean("login_skipped")) {
super.onBackPressed();
} else {
setSelectedItemId(NavTab.CONTRIBUTIONS.code());
}
}
} else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) {
// Means that bookmark fragment is visible
bookmarkFragment.onBackPressed();
} else {
super.onBackPressed();
}
}
@Override
public void onBackStackChanged() {
//initBackButton();
}
/**
* Retry all failed uploads as soon as the user returns to the app
*/
@SuppressLint("CheckResult")
private void retryAllFailedUploads() {
contributionDao.
getContribution(Collections.singletonList(Contribution.STATE_FAILED))
.subscribeOn(Schedulers.io())
.subscribe(failedUploads -> {
for (Contribution contribution : failedUploads) {
contributionsFragment.retryUpload(contribution);
}
});
}
/**
* Handles item selection in the options menu. This method is called when a user interacts with
* the options menu in the Top Bar.
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.upload_tab:
startActivity(new Intent(this, UploadProgressActivity.class));
return true;
case R.id.notifications:
// Starts notification activity on click to notification icon
NotificationActivity.Companion.startYourself(this, "unread");
return true;
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;
}
}

View file

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

View file

@ -1,126 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.WallpaperManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.facebook.common.executors.CallerThreadExecutor;
import com.facebook.common.references.CloseableReference;
import com.facebook.datasource.DataSource;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imagepipeline.core.ImagePipeline;
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber;
import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.R;
import timber.log.Timber;
public class SetWallpaperWorker extends Worker {
private static final String NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel";
private static final int NOTIFICATION_ID = 1;
public SetWallpaperWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
Context context = getApplicationContext();
createNotificationChannel(context);
showProgressNotification(context);
String imageUrl = getInputData().getString("imageUrl");
if (imageUrl == null) {
return Result.failure();
}
ImageRequest imageRequest = ImageRequestBuilder
.newBuilderWithSource(Uri.parse(imageUrl))
.build();
ImagePipeline imagePipeline = Fresco.getImagePipeline();
final DataSource<CloseableReference<CloseableImage>>
dataSource = imagePipeline.fetchDecodedImage(imageRequest, context);
dataSource.subscribe(new BaseBitmapDataSubscriber() {
@Override
public void onNewResultImpl(@Nullable Bitmap bitmap) {
if (dataSource.isFinished() && bitmap != null) {
Timber.d("Bitmap loaded from url %s", imageUrl.toString());
setWallpaper(context, Bitmap.createBitmap(bitmap));
dataSource.close();
}
}
@Override
public void onFailureImpl(DataSource dataSource) {
Timber.d("Error getting bitmap from image url %s", imageUrl.toString());
showNotification(context, "Setting Wallpaper Failed", "Failed to download image.");
if (dataSource != null) {
dataSource.close();
}
}
}, CallerThreadExecutor.getInstance());
return Result.success();
}
private void setWallpaper(Context context, Bitmap bitmap) {
WallpaperManager wallpaperManager = WallpaperManager.getInstance(context);
try {
wallpaperManager.setBitmap(bitmap);
showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.");
} catch (Exception e) {
Timber.e(e, "Error setting wallpaper");
showNotification(context, "Setting Wallpaper Failed", " "+e.getLocalizedMessage());
}
}
private void showProgressNotification(Context context) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle("Setting Wallpaper")
.setContentText("Please wait...")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setProgress(0, 0, true);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
private void showNotification(Context context, String title, String content) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(false);
notificationManager.notify(NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = "Wallpaper Setting";
String description = "Notifications for wallpaper setting progress";
int importance = NotificationManager.IMPORTANCE_HIGH;
NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance);
channel.setDescription(description);
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.createNotificationChannel(channel);
}
}
}

View file

@ -0,0 +1,113 @@
package fr.free.nrw.commons.contributions
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.WallpaperManager
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.facebook.common.executors.CallerThreadExecutor
import com.facebook.common.references.CloseableReference
import com.facebook.datasource.DataSource
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber
import com.facebook.imagepipeline.image.CloseableImage
import com.facebook.imagepipeline.request.ImageRequestBuilder
import fr.free.nrw.commons.R
import timber.log.Timber
class SetWallpaperWorker(context: Context, params: WorkerParameters) :
Worker(context, params) {
override fun doWork(): Result {
val context = applicationContext
createNotificationChannel(context)
showProgressNotification(context)
val imageUrl = inputData.getString("imageUrl") ?: return Result.failure()
val imageRequest = ImageRequestBuilder
.newBuilderWithSource(Uri.parse(imageUrl))
.build()
val imagePipeline = Fresco.getImagePipeline()
val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context)
dataSource.subscribe(object : BaseBitmapDataSubscriber() {
public override fun onNewResultImpl(bitmap: Bitmap?) {
if (dataSource.isFinished && bitmap != null) {
Timber.d("Bitmap loaded from url %s", imageUrl.toString())
setWallpaper(context, Bitmap.createBitmap(bitmap))
dataSource.close()
}
}
override fun onFailureImpl(dataSource: DataSource<CloseableReference<CloseableImage>>?) {
Timber.d("Error getting bitmap from image url %s", imageUrl.toString())
showNotification(context, "Setting Wallpaper Failed", "Failed to download image.")
dataSource?.close()
}
}, CallerThreadExecutor.getInstance())
return Result.success()
}
private fun setWallpaper(context: Context, bitmap: Bitmap) {
val wallpaperManager = WallpaperManager.getInstance(context)
try {
wallpaperManager.setBitmap(bitmap)
showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.")
} catch (e: Exception) {
Timber.e(e, "Error setting wallpaper")
showNotification(context, "Setting Wallpaper Failed", " " + e.localizedMessage)
}
}
private fun showProgressNotification(context: Context) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle("Setting Wallpaper")
.setContentText("Please wait...")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setProgress(0, 0, true)
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
private fun showNotification(context: Context, title: String, content: String) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.commons_logo)
.setContentTitle(title)
.setContentText(content)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(false)
notificationManager.notify(NOTIFICATION_ID, builder.build())
}
private fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name: CharSequence = "Wallpaper Setting"
val description = "Notifications for wallpaper setting progress"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance)
channel.description = description
val notificationManager = context.getSystemService(
NotificationManager::class.java
)
notificationManager.createNotificationChannel(channel)
}
}
companion object {
private const val NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel"
private const val NOTIFICATION_ID = 1
}
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.ViewPager;
public class UnswipableViewPager extends ViewPager{
public UnswipableViewPager(@NonNull Context context) {
super(context);
}
public UnswipableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
// Unswipable
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// Unswipable
return false;
}
}

View file

@ -0,0 +1,22 @@
package fr.free.nrw.commons.contributions
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
class UnswipableViewPager : ViewPager {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
// Unswipable
return false
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// Unswipable
return false
}
}

View file

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

View file

@ -9,6 +9,7 @@ import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.activity.SingleWebViewActivity
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.contributions.ContributionsModule
import fr.free.nrw.commons.contributions.ContributionsProvidesModule
import fr.free.nrw.commons.explore.SearchModule
import fr.free.nrw.commons.explore.categories.CategoriesModule
import fr.free.nrw.commons.explore.depictions.DepictionModule
@ -40,6 +41,7 @@ import javax.inject.Singleton
ContentProviderBuilderModule::class,
UploadModule::class,
ContributionsModule::class,
ContributionsProvidesModule::class,
SearchModule::class,
DepictionModule::class,
CategoriesModule::class

View file

@ -15,8 +15,8 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje
@Inject @JvmField
var childFragmentInjector: DispatchingAndroidInjector<Fragment>? = null
@JvmField
protected var compositeDisposable: CompositeDisposable = CompositeDisposable()
// Removed @JvmField to allow overriding
protected open var compositeDisposable: CompositeDisposable = CompositeDisposable()
override fun onAttach(context: Context) {
inject()
@ -63,4 +63,9 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje
return getInstance(activity.applicationContext)
}
// Ensure getContext() returns a non-null Context
override fun getContext(): Context {
return super.getContext() ?: throw IllegalStateException("Context is null")
}
}

View file

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

View file

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

View file

@ -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);
if (!isCameFromNearbyMap()) {
binding.mapView.getController().setZoom(ZOOM_LEVEL);
}
performMapReadyActions();
binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() {
@ -328,11 +342,51 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
isPermissionDenied = true;
}
lastKnownLocation = MapUtils.getDefaultLatLng();
// 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,6 +701,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
* @param nearbyBaseMarker The NearbyBaseMarker object representing the marker to be added.
*/
private void addMarkerToMap(BaseMarker nearbyBaseMarker) {
if (isAttachedToActivity()) {
ArrayList<OverlayItem> items = new ArrayList<>();
Bitmap icon = nearbyBaseMarker.getIcon();
Drawable d = new BitmapDrawable(getResources(), icon);
@ -659,7 +721,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
removeMarker(clickedMarker);
addMarkerToMap(clickedMarker);
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
bottomSheetDetailsBehavior.setState(
BottomSheetBehavior.STATE_COLLAPSED);
}
clickedMarker = nearbyBaseMarker;
passInfoToSheet(place);
@ -675,6 +738,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
overlay.setFocusItemsOnTap(true);
binding.mapView.getOverlays().add(overlay); // Add the overlay to the map
}
}
/**
* Removes a marker from the map based on the specified NearbyBaseMarker.
@ -707,6 +771,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
*/
@Override
public void clearAllMarkers() {
if (isAttachedToActivity()) {
binding.mapView.getOverlayManager().clear();
GeoPoint geoPoint = mapCenter;
if (geoPoint != null) {
@ -732,7 +797,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
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));
ContextCompat.getDrawable(this.getContext(),
R.drawable.current_location_marker));
startMarker.setTitle("Your Location");
startMarker.setTextLabelFontSize(24);
binding.mapView.getOverlays().add(startMarker);
@ -754,7 +820,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (bottomSheetDetailsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
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()) {
@ -770,6 +837,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
}));
binding.mapView.setMultiTouchControls(true);
}
}
/**
* Recenters the map view to the specified GeoPoint and updates the marker to indicate the new
@ -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() {
}
}

View file

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

View file

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

View file

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

View file

@ -493,7 +493,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
val contributionsFragment: ContributionsFragment? = this.getContributionsFragmentParent()
if (contributionsFragment?.binding != null) {
contributionsFragment.binding.cardViewNearby.visibility = View.GONE
contributionsFragment.binding!!.cardViewNearby.visibility = View.GONE
}
// detail provider is null when fragment is shown in review activity
@ -650,10 +650,8 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
}
private fun onDepictionsLoaded(idAndCaptions: List<IdAndCaptions>) {
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<IdAndCaptions>) {
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.

View file

@ -31,8 +31,8 @@ class NavTabLayout : BottomNavigationView {
private fun setTabViews() {
val isLoginSkipped = (context as MainActivity)
.applicationKvStore.getBoolean("login_skipped")
if (isLoginSkipped) {
.applicationKvStore?.getBoolean("login_skipped")
if (isLoginSkipped == true) {
for (i in 0 until NavTabLoggedOut.size()) {
val navTab = NavTabLoggedOut.of(i)
menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon())

View file

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

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@ class ResultTuple {
}
constructor() {
language = ""
language = "bug" // Basa Ugi language - TODO Respect the `Default description language` setting.
type = ""
value = ""
}

View file

@ -25,9 +25,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.internal.wait
import timber.log.Timber
import java.io.IOException
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
@ -75,8 +78,8 @@ class NearbyParentFragmentPresenter
* - **connnectionCount**: number of parallel requests
*/
private object LoadPlacesAsyncOptions {
const val BATCH_SIZE = 3
const val CONNECTION_COUNT = 3
const val BATCH_SIZE = 10
const val CONNECTION_COUNT = 20
}
private var schedulePlacesUpdateJob: Job? = null
@ -91,7 +94,7 @@ class NearbyParentFragmentPresenter
private object SchedulePlacesUpdateOptions {
var skippedCount = 0
const val SKIP_LIMIT = 3
const val SKIP_DELAY_MS = 500L
const val SKIP_DELAY_MS = 100L
}
// used to tell the asynchronous place detail loading job that the places' bookmarked status
@ -379,13 +382,32 @@ class NearbyParentFragmentPresenter
)
} catch (e: Exception) {
Timber.tag("NearbyPinDetails").e(e)
collectResults.send(indices.map { Pair(it, updatedGroups[it]) })
//HTTP request failed. Try individual places
for (i in indices) {
launch {
val onePlaceBatch = mutableListOf<Pair<Int, MarkerPlaceGroup>>()
try {
val fetchedPlace = nearbyController.getPlaces(
mutableListOf(updatedGroups[i].place)
)
onePlaceBatch.add(Pair(i, MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(
fetchedPlace[0]),fetchedPlace[0])))
} catch (e: Exception) {
Timber.tag("NearbyPinDetails").e(e)
onePlaceBatch.add(Pair(i, updatedGroups[i]))
}
collectResults.send(onePlaceBatch)
}
}
}
}
}
}
var collectCount = 0
for (resultList in collectResults) {
while (collectCount < indicesToUpdate.size) {
val resultList = collectResults.receive()
for ((index, fetchedPlaceGroup) in resultList) {
val existingPlace = updatedGroups[index].place
val finalPlaceGroup = MarkerPlaceGroup(
@ -442,9 +464,7 @@ class NearbyParentFragmentPresenter
}
}
schedulePlacesUpdate(updatedGroups)
if (++collectCount == totalBatches) {
break
}
collectCount += resultList.size
}
collectResults.close()
}

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.repository
import android.net.Uri
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.category.CategoriesModel
import fr.free.nrw.commons.category.CategoryItem
@ -203,8 +204,8 @@ class UploadRepository @Inject constructor(
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String): Single<Int> {
return uploadModel.checkDuplicateImage(filePath)
fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single<Int> {
return uploadModel.checkDuplicateImage(originalFilePath, modifiedFilePath)
}
/**

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons.upload
import android.content.Context
import android.net.Uri
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.nearby.Place
@ -13,7 +15,7 @@ import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
import java.io.FileInputStream
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
@ -26,7 +28,8 @@ class ImageProcessingService @Inject constructor(
private val imageUtilsWrapper: ImageUtilsWrapper,
private val readFBMD: ReadFBMD,
private val exifReader: EXIFReader,
private val mediaClient: MediaClient
private val mediaClient: MediaClient,
private val appContext: Context
) {
/**
* Check image quality before upload - checks duplicate image - checks dark image - checks
@ -47,7 +50,10 @@ class ImageProcessingService @Inject constructor(
val filePath = uploadItem.mediaUri?.path
return Single.zip(
checkDuplicateImage(filePath),
checkIfFileAlreadyExists(
originalFilePath = uploadItem.contentUri!!,
modifiedFilePath = uploadItem.mediaUri!!
),
checkImageGeoLocation(uploadItem.place, filePath, inAppPictureLocation),
checkDarkImage(filePath!!),
checkFBMD(filePath),
@ -114,18 +120,31 @@ class ImageProcessingService @Inject constructor(
.subscribeOn(Schedulers.io())
}
/**
* Checks if file already exists using original & modified file's SHA
*
* @param originalFilePath original file to be checked
* @param modifiedFilePath modified (after exif modifications) file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkIfFileAlreadyExists(originalFilePath: Uri, modifiedFilePath: Uri): Single<Int> {
return Single.zip(
checkDuplicateImage(inputStream = appContext.contentResolver.openInputStream(originalFilePath)!!),
checkDuplicateImage(inputStream = fileUtilsWrapper.getFileInputStream(modifiedFilePath.path))
) { resultForOriginal, resultForDuplicate ->
return@zip if (resultForOriginal == IMAGE_DUPLICATE || resultForDuplicate == IMAGE_DUPLICATE)
IMAGE_DUPLICATE else IMAGE_OK
}
}
/**
* Checks for duplicate image
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String?): Single<Int> {
Timber.d("Checking for duplicate image %s", filePath)
return Single.fromCallable { fileUtilsWrapper.getFileInputStream(filePath) }
.map { stream: FileInputStream? ->
fileUtilsWrapper.getSHA1(stream)
}
private fun checkDuplicateImage(inputStream: InputStream): Single<Int> {
return Single.fromCallable { fileUtilsWrapper.getSHA1(inputStream) }
.flatMap { fileSha: String? ->
mediaClient.checkFileExistsUsingSha(fileSha)
}

View file

@ -28,8 +28,7 @@ import javax.inject.Named
/**
* The presenter class for PendingUploadsFragment and FailedUploadsFragment
*/
class PendingUploadsPresenter @Inject internal constructor(
*/ class PendingUploadsPresenter @Inject internal constructor(
private val contributionBoundaryCallback: ContributionBoundaryCallback,
private val contributionsRemoteDataSource: ContributionsRemoteDataSource,
private val contributionsRepository: ContributionsRepository,
@ -89,13 +88,17 @@ class PendingUploadsPresenter @Inject internal constructor(
* @param context The context in which the operation is being performed.
*/
override fun deleteUpload(contribution: Contribution?, context: Context?) {
compositeDisposable.add(
contribution?.let {
contributionsRepository
.deleteContributionFromDB(contribution)
.deleteContributionFromDB(it)
.subscribeOn(ioThreadScheduler)
.subscribe()
}?.let {
compositeDisposable.add(
it
)
}
}
/**
* Pauses all the uploads by changing the state of contributions from STATE_QUEUED and
@ -149,7 +152,10 @@ class PendingUploadsPresenter @Inject internal constructor(
}
compositeDisposable.add(
uploadRepository
.checkDuplicateImage(contribution.localUriPath!!.path)
.checkDuplicateImage(
originalFilePath = contribution.contentUri!!,
modifiedFilePath = contribution.localUri!!
)
.subscribeOn(ioThreadScheduler)
.subscribe({ imageCheckResult: Int ->
if (imageCheckResult == IMAGE_OK) {
@ -218,7 +224,10 @@ class PendingUploadsPresenter @Inject internal constructor(
}
compositeDisposable.add(
uploadRepository
.checkDuplicateImage(contribution.localUriPath!!.path)
.checkDuplicateImage(
originalFilePath = contribution.contentUri!!,
modifiedFilePath = contribution.localUri!!
)
.subscribeOn(ioThreadScheduler)
.subscribe { imageCheckResult: Int ->
if (imageCheckResult == IMAGE_OK) {

View file

@ -679,7 +679,7 @@ class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.C
}
private fun receiveExternalSharedItems() {
uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent)
uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent).toMutableList()
}
private fun receiveInternalSharedItems() {

View file

@ -103,8 +103,11 @@ class UploadModel @Inject internal constructor(
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String?): Single<Int> =
imageProcessingService.checkDuplicateImage(filePath)
fun checkDuplicateImage(originalFilePath: Uri?, modifiedFilePath: Uri?): Single<Int> =
imageProcessingService.checkIfFileAlreadyExists(
originalFilePath = originalFilePath!!,
modifiedFilePath = modifiedFilePath!!
)
/**
* Calls validateCaption() of ImageProcessingService to check caption of image

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.upload.categories
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
@ -89,6 +90,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
}
@SuppressLint("StringFormatMatches")
private fun init() {
if (binding == null) {
return
@ -372,8 +374,9 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
(requireActivity() as AppCompatActivity).supportActionBar?.hide()
if (parentFragment?.parentFragment?.parentFragment is ContributionsFragment) {
((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = View.GONE
((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding?.cardViewNearby?.visibility = View.GONE
}
}
}

View file

@ -15,6 +15,7 @@ import android.widget.CompoundButton
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
@ -60,8 +61,7 @@ import javax.inject.Named
class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContract.View,
UploadMediaDetailAdapter.EventListener {
private val startForResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onCameraPosition)
private lateinit var startForResult: ActivityResultLauncher<Intent>
private val startForEditActivityResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onEditActivityResult)
@ -135,6 +135,10 @@ class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContra
if (savedInstanceState != null && uploadableFile == null) {
uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE)
}
// Register the ActivityResultLauncher for LocationPickerActivity
startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
onCameraPosition(result)
}
}
fun setImageToBeUploaded(

View file

@ -51,7 +51,7 @@ class UploadMediaPresenter @Inject constructor(
}
}
}
lateinit var basicKvStoreFactory: (String) -> BasicKvStore
private var basicKvStoreFactory: ((String) -> BasicKvStore)? = null
override fun onAttachView(view: UploadMediaDetailsContract.View) {
this.view = view
@ -339,8 +339,8 @@ class UploadMediaPresenter @Inject constructor(
*/
override fun checkImageQuality(uploadItem: UploadItem, index: Int) {
if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) }
?.getString(UPLOAD_QUALITIES_KEY, null)
try {
val imageQuality = value.asJsonObject()["UploadItem$index"] as Int
view.showProgress(false)
@ -363,8 +363,8 @@ class UploadMediaPresenter @Inject constructor(
* @param index Index of the UploadItem which was deleted
*/
override fun updateImageQualitiesJSON(size: Int, index: Int) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
val value = basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) }
?.getString(UPLOAD_QUALITIES_KEY, null)
try {
val jsonObject = value.asJsonObject().apply {
for (i in index until (size - 1)) {
@ -372,8 +372,8 @@ class UploadMediaPresenter @Inject constructor(
}
remove("UploadItem" + (size - 1))
}
basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
basicKvStoreFactory?.let { it(UploadActivity.storeNameForCurrentUploadImagesSize) }
?.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
} catch (e: Exception) {
Timber.e(e)
}

View file

@ -149,7 +149,7 @@ class UploadWorker(
currentNotification.build(),
)
contribution!!.transferred = transferred
contributionDao.update(contribution).blockingAwait()
contributionDao.update(contribution!!).blockingAwait()
}
open fun onChunkUploaded(
@ -469,7 +469,7 @@ class UploadWorker(
contribution: Contribution,
) {
val wikiDataPlace = contribution.wikidataPlace
if (wikiDataPlace != null && wikiDataPlace.imageValue == null) {
if (wikiDataPlace != null) {
if (!contribution.hasInvalidLocation()) {
var revisionID: Long? = null
try {

View file

@ -59,10 +59,12 @@
<fr.free.nrw.commons.ui.PasteSensitiveTextInputEditText
android:id="@+id/caption_item_edit_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:minLines="1"
android:maxLines="10"
android:hint="@string/share_caption_hint"
android:imeOptions="actionNext|flagNoExtractUi"
android:inputType="text"
android:inputType="textMultiLine"
app:allowFormatting="false" />
</com.google.android.material.textfield.TextInputLayout>
@ -103,7 +105,9 @@
<fr.free.nrw.commons.ui.PasteSensitiveTextInputEditText
android:id="@+id/description_item_edit_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:minLines="1"
android:maxLines="10"
android:hint="@string/share_description_hint"
android:imeOptions="actionNext|flagNoExtractUi"
android:inputType="textMultiLine"

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
>
<item
android:id="@+id/action_search"
android:title="@string/menu_search_button"
android:icon="?attr/search_icon"
android:orderInCategory="1"
app:showAsAction="ifRoom"
/>
<item android:id="@+id/list_item_show_in_nearby"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:title="@string/show_in_nearby"
android:visible="false"
/>
</menu>

View file

@ -12,6 +12,12 @@
android:icon="@drawable/ic_list_white_24dp"
/>
<item android:id="@+id/list_item_show_in_explore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:title="@string/show_in_explore"
/>
<item android:id="@+id/list_item_gpx"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View file

@ -5,6 +5,7 @@
* Asma
* Azouz.anis
* ButterflyOfFire
* Cigaryno
* Claw eg
* Dr-Taher
* Dr. Mohammed
@ -20,6 +21,7 @@
* NancyMilad
* OsamaK
* Tala Ali
* XIDME
* أيوب
* أَحمد
* ترجمان05
@ -408,7 +410,7 @@
<string name="error_fetching_nearby_monuments">خطأ في جلب المعالم القريبة.</string>
<string name="no_recent_searches">لا توجد عمليات بحث حديثة</string>
<string name="delete_recent_searches_dialog">هل أنت متأكد من أنك تريد مسح سجل بحثك؟</string>
<string name="cancel_upload_dialog">هل انت متأكد انك تريد الغاء هذا التحميل</string>
<string name="cancel_upload_dialog">هل أنت متأكد أنك تريد إلغاء هذا التحميل؟</string>
<string name="delete_search_dialog">هل تريد حذف هذا البحث؟</string>
<string name="search_history_deleted">تم حذف سجل البحث</string>
<string name="nominate_delete">ترشيح للحذف</string>
@ -883,4 +885,6 @@
<string name="caption">الشرح</string>
<string name="caption_copied_to_clipboard">تم نسخ التسمية التوضيحية إلى الحافظة</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل.</string>
<string name="show_in_explore">عرض في استكشاف</string>
<string name="show_in_nearby">عرض في المناطق القريبة</string>
</resources>

View file

@ -145,7 +145,7 @@
<string name="no_uploads_yet">Inda nun xubió denguna foto.</string>
<string name="menu_retry_upload">Reintentar</string>
<string name="menu_cancel_upload">Zarrar</string>
<string name="media_upload_policy">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 &lt;a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\"&gt;polítiques de Wikimedia Commons&lt;/a&gt;.</string>
<string name="media_upload_policy">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 &lt;a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\"&gt;polítiques de Wikimedia Commons&lt;/a&gt;.</string>
<string name="menu_download">Descargar</string>
<string name="preference_license">Llicencia predeterminada</string>
<string name="use_previous">Usar un títulu y descripción anterior</string>

View file

@ -10,6 +10,7 @@
* Toghrul Rahimli
* Wertuose
* Şeyx Şamil
* Əkrəm
* Əkrəm Cəfər
-->
<resources>

View file

@ -71,7 +71,7 @@
<string name="login">ЧугӀо</string>
<string name="forgot_password">Йицйелла пароль?</string>
<string name="signup">ДӀайаздалар</string>
<string name="logging_in_title">Системин чудахар</string>
<string name="logging_in_title">Системин чу дахар</string>
<string name="logging_in_message">Дехар до, собарде…</string>
<string name="updating_caption_title">титраш а, йийцарш а карладохуш ду..</string>
<string name="updating_caption_message">Дехар до, собарде…</string>

View file

@ -821,4 +821,6 @@
<string name="caption">Billedtekst</string>
<string name="caption_copied_to_clipboard">Billedtekst kopieret til udklipsholder</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Tillykke, alle billeder i dette album er enten blevet uploadet eller markeret som ikke til upload.</string>
<string name="show_in_explore">Vis i Udforsk</string>
<string name="show_in_nearby">Vis i I nærheden</string>
</resources>

View file

@ -349,4 +349,6 @@
<string name="read_help_link">Tayêna bıwane</string>
<string name="description">Şınasnayış</string>
<string name="reset">Raçarne</string>
<string name="account">Hesab</string>
<string name="caption">Bınnuşte</string>
</resources>

View file

@ -855,4 +855,6 @@
<string name="file_usages_container_heading">Utilisations du fichier</string>
<string name="caption">Légende</string>
<string name="caption_copied_to_clipboard">Légende copiée dans le presse-papier</string>
<string name="show_in_explore">Afficher dans Explorer</string>
<string name="show_in_nearby">Afficher à proximité</string>
</resources>

View file

@ -6,6 +6,7 @@
* Anandra
* AnupamM
* Bhatakati aatma
* Bunnypranav
* Gopalindians
* Nilesh shukla
* Nitin1485
@ -28,33 +29,47 @@
<string name="commons_github">कॉमन्स गिटहब सोर्स कोड</string>
<string name="commons_logo">कॉमन्स का प्रतीक चिन्ह</string>
<string name="commons_website">कॉमन्स का जालस्थान</string>
<string name="exit_location_picker">निकास स्थान चयनकर्ता</string>
<string name="submit">जमा करें</string>
<string name="add_another_description">एक और विवरण जोड़ें</string>
<string name="add_new_contribution">नया योगदान</string>
<string name="add_contribution_from_camera">कैमरे से योगदान जोड़ें</string>
<string name="add_contribution_from_photos">फ़ोटो से योगदान जोड़ें</string>
<string name="add_contribution_from_contributions_gallery">पिछले योगदान गैलरी से योगदान जोड़ें</string>
<string name="show_captions">कैप्शन</string>
<string name="row_item_language_description">भाषा विवरण</string>
<string name="row_item_caption">कैप्शन</string>
<string name="show_captions_description">विवरण</string>
<string name="nearby_row_image">चित्र</string>
<string name="nearby_all">सभी</string>
<string name="nearby_filter_toggle">ऊपर टॉगल करें</string>
<string name="nearby_filter_state">स्थान राज्य</string>
<string name="appwidget_img">आज का चित्र</string>
<plurals name="uploads_pending_notification_indicator">
<item quantity="one">%1$d फ़ाइल अपलोड हो रही</item>
<item quantity="other">%1$d फ़ाइलें अपलोड हो रहीं</item>
</plurals>
<plurals name="contributions_subtitle" fuzzy="true">
<item quantity="zero">\@string/contributions_subtitle_zero</item>
<plurals name="contributions_subtitle">
<item quantity="one">(%1$d)</item>
<item quantity="other">(%1$d)</item>
</plurals>
<plurals name="starting_multiple_uploads" fuzzy="true">
<item quantity="one">%1$d अपलोड शुरू</item>
<item quantity="other">%1$d अपलोड शुरू</item>
<string name="starting_uploads">अपलोड शुरू</string>
<plurals name="starting_multiple_uploads">
<item quantity="one">%d अपलोड संसाधित</item>
<item quantity="other">%d अपलोड संसाधित</item>
</plurals>
<plurals name="multiple_uploads_title" fuzzy="true">
<item quantity="one">%1$d अपलोड</item>
<item quantity="other">%1$d अपलोड</item>
<plurals name="multiple_uploads_title">
<item quantity="one">%d अपलोड</item>
<item quantity="other">%d अपलोड</item>
</plurals>
<plurals name="share_license_summary">
<item quantity="one">इस चित्र का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा</item>
<item quantity="other">इन चित्रों का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा</item>
</plurals>
<plurals name="upload_count_title">
<item quantity="one">%1$d अपलोड</item>
<item quantity="other">%1$d अपलोड</item>
</plurals>
<string name="navigation_item_explore">खोजें</string>
<string name="preference_category_appearance">स्वरूप</string>
<string name="preference_category_general">सामान्य</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Authors:
* A100Star
* Ademo
* Alefar
* Amire80
@ -27,7 +28,7 @@
<string name="commons_website">אתר ויקישיתוף</string>
<string name="exit_location_picker">יציאה מבורר המיקום</string>
<string name="submit">שליחה</string>
<string name="add_another_description">הוספת יאור אחר</string>
<string name="add_another_description">הוספת תיאור אחר</string>
<string name="add_new_contribution">הוספת תרומה חדשה</string>
<string name="add_contribution_from_camera">הוספת תרומה ממצלמה</string>
<string name="add_contribution_from_photos">הוספת תרומה מ־Photos</string>
@ -38,7 +39,7 @@
<string name="show_captions_description">תיאור</string>
<string name="nearby_row_image">תמונה</string>
<string name="nearby_all">הכול</string>
<string name="nearby_filter_toggle">להפעיל למעלה</string>
<string name="nearby_filter_toggle">הפעלה</string>
<string name="nearby_filter_search">תצוגת חיפוש</string>
<string name="nearby_filter_state">מצב המקום</string>
<string name="appwidget_img">תמונת היום</string>
@ -85,7 +86,7 @@
<item quantity="many">מתקבל תוכן שיתופי. עיבוד התמונות עשוי לארוך זמן מה כתלות בגודל התמונות והמכשיר שלך</item>
<item quantity="other">מתקבל תוכן שיתופי. עיבוד התמונות עשוי לארוך זמן מה כתלות בגודל התמונות והמכשיר שלך</item>
</plurals>
<string name="navigation_item_explore">לחקור</string>
<string name="navigation_item_explore">סיור</string>
<string name="preference_category_appearance">מראה</string>
<string name="preference_category_general">כללי</string>
<string name="preference_category_feedback">משוב</string>
@ -149,7 +150,7 @@
<string name="provider_modifications">שינויים</string>
<string name="menu_upload_single">העלאה</string>
<string name="categories_search_text_hint">חיפוש קטגוריות</string>
<string name="depicts_search_text_hint">חפשו פריטים שהמדיה שלך מציגה (הר, טאג\' מהאל, וכו\')</string>
<string name="depicts_search_text_hint">חפשו פריטים שהמדיה שלכם מציגה (הר, טאג\' מהאל, וכו\')</string>
<string name="menu_save_categories">שמירה</string>
<string name="menu_overflow_desc">תפריט גלישה</string>
<string name="refresh_button">רענון</string>
@ -311,7 +312,7 @@
<string name="nearby_showing_pins_offline">האינטרנט אינו זמין. מוצגים רק מקומות שמורים.</string>
<string name="upload_location_access_denied">הגישה למיקום נדחתה. נא להגדיר את המקום שלך ידנית כדי להשתמש ביכולת הזאת.</string>
<string name="location_permission_rationale_nearby">נדרשת הרשאה כדי להציג רשימה של מקומות בסביבה</string>
<string name="location_permission_rationale_explore">נדרשת הרשאה להצגת רשימת התמונות בסביבתך</string>
<string name="location_permission_rationale_explore">נדרשת הרשאה כדי להציג רשימה של תמונות בסביבה</string>
<string name="nearby_directions">כיוונים</string>
<string name="nearby_wikidata">ויקינתונים</string>
<string name="nearby_wikipedia">ויקיפדיה</string>
@ -605,12 +606,12 @@
<string name="place_state_exists">קיים</string>
<string name="place_state_needs_photo">זקוק לתצלום</string>
<string name="place_type">סוג המקום:</string>
<string name="nearby_search_hint">גשר, מוזאון, מלון, וכו\'.</string>
<string name="nearby_search_hint">גשר, מוזאון, מלון וכו\'.</string>
<string name="you_must_reset_your_passsword">משהו השתבש בכניסה למערכת, עליך לאפס את הסיסמה שלך!</string>
<string name="title_for_media">מדיה</string>
<string name="title_for_child_classes">מחלקות יורשות</string>
<string name="title_for_parent_classes">מחלקות מורישות</string>
<string name="upload_nearby_place_found_title">נמצא בקרבת מקום</string>
<string name="upload_nearby_place_found_title">נמצא מקום בסביבה</string>
<string name="upload_nearby_place_found_description_plural">האם אלו תמונות של %1$s?</string>
<string name="upload_nearby_place_found_description_singular">האם זאת תמונה של %1$s?</string>
<string name="title_app_shortcut_bookmark">סימניות</string>
@ -628,7 +629,7 @@
<string name="ask_to_turn_location_on">להפעיל מיקום?</string>
<string name="ask_to_turn_location_on_text">נא להפעיל את שירותי המיקום כדי שהיישום יוכל להציג את מיקומך הנוכחי</string>
<string name="nearby_needs_location">פעולת \"בסביבה\" זקוקה לשירותי מיקומי פועלים כדי לעבוד כמו שצריך</string>
<string name="explore_map_needs_location">חקירת המפה דורשת הרשאות מיקום כדי להציג תמונות בסביבתך</string>
<string name="explore_map_needs_location">מפת \"סיור\" דורשת הרשאות מיקום כדי להציג תמונות בסביבתך</string>
<string name="upload_map_location_access">יש להעניק הרשאת מיקום כדי להגדיר את המיקום אוטומטית.</string>
<string name="use_location_from_similar_image">האם צילמת את שתי התמונות באותו המקום? האם ברצונך להשתמש בקו הרוחב וקו האורך של התמונה משמאל?</string>
<string name="load_more">לטעון עוד</string>
@ -846,9 +847,14 @@
<string name="usages_on_other_wikis_heading">אתרי ויקי אחרים</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">שימושים בקובץ</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</string>
<string name="account">חשבון</string>
<string name="vanish_account">העלמת חשבון</string>
<string name="account_vanish_request_confirm_title">אזהרת העלמת חשבון</string>
<string name="account_vanish_request_confirm">היעלמות היא &lt;b&gt;מוצא אחרון&lt;/b&gt; וצריך &lt;b&gt;להשתמש בו רק כאשר אתם רוצים להפסיק לערוך לנצח&lt;/b&gt; וגם כדי להסתיר כמה שיותר מהאסוציאציות שלכם בעבר.&lt;br/&gt;&lt;br/&gt;מחיקת חשבון בויקישיתוף נעשית על ידי שינוי שם החשבון שלכם כך שאחרים לא יוכלו לזהות את התרומות שלכם בתהליך שנקרא חשבון היעלמות. &lt;b&gt;היעלמות אינה מבטיחה אנונימיות מוחלטת או הסרה של תרומות לפרויקטים&lt;/b&gt;.</string>
<string name="caption">כותרת</string>
<string name="caption_copied_to_clipboard">הכותרת הועתקה ללוח</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">ברכותינו, כל התמונות באלבום הזה הועלו או שסומנו לא להעלאה.</string>
<string name="show_in_explore">בתצוגת סיור</string>
<string name="show_in_nearby">בתצוגת בסביבה</string>
</resources>

View file

@ -709,6 +709,7 @@
<string name="file_usages_container_heading">이 파일을 사용하는 문서</string>
<string name="account">계정</string>
<string name="vanish_account">계정 버리기</string>
<string name="account_vanish_request_confirm">버리기는 &lt;b&gt;최후의 수단&lt;/b&gt;이며 &lt;b&gt;영원히 편집을 중단하고 싶을 때&lt;/b&gt;와 가능한 한 많은 과거 연관성을 숨기고 싶을 때만 사용해야 합니다.&lt;br/&gt;&lt;br/&gt;위키미디어 공용에서 계정을 삭제하려면 계정 이름을 변경하여 다른 사람이 기여한 내용을 알아볼 수 없도록 하는 계정 버리기라는 프로세스를 거쳐야 합니다. &lt;b&gt;버리기는 완전한 익명성을 보장하지 않으며 프로젝트에 수행한 기여를 제거하지 않습니다&lt;/b&gt;.</string>
<string name="caption">캡션</string>
<string name="caption_copied_to_clipboard">캡션이 클립보드에 복사되었습니다</string>
</resources>

View file

@ -96,6 +96,8 @@
<string name="menu_from_camera">Nufotografuoti</string>
<string name="menu_nearby">Netoliese</string>
<string name="provider_contributions">Mano įkėlimai</string>
<string name="menu_copy_link">Kopijuoti nuorodą</string>
<string name="menu_link_copied">Nuoroda nukopijuota į mainų sritį.</string>
<string name="menu_share">Dalintis</string>
<string name="menu_view_file_page">Žiūrėti failo puslapį</string>
<string name="share_title_hint">Antraštė (būtina)</string>
@ -345,11 +347,13 @@
<string name="delete">Ištrinti</string>
<string name="Achievements">Pasiekimai</string>
<string name="Profile">Profilis</string>
<string name="badges">Ženkliukai</string>
<string name="statistics">Statistika</string>
<string name="statistics_thanks">Gauta padėka</string>
<string name="statistics_featured">Rinktiniai paveikslėliai</string>
<string name="statistics_wikidata_edits">Vaizdai per „Netoliese esančios vietos“</string>
<string name="level" fuzzy="true">Lygis</string>
<string name="level">Lygis %d</string>
<string name="profileLevel">%s (%s lygis)</string>
<string name="images_uploaded">Vaizdai įkelti</string>
<string name="image_reverts">Paveikslėliai negrąžinti</string>
<string name="images_used_by_wiki">Naudoti vaizdai</string>
@ -381,6 +385,7 @@
<string name="map_application_missing">Jūsų įrenginyje nepavyko rasti suderinamos žemėlapio programos. Norėdami naudotis šia funkcija, įdiekite žemėlapio programą.</string>
<string name="title_page_bookmarks_pictures">Nuotraukos</string>
<string name="title_page_bookmarks_locations">Vietos</string>
<string name="title_page_bookmarks_categories">Kategorijos</string>
<string name="menu_bookmark">Pridėti prie / pašalinti iš žymių</string>
<string name="provider_bookmarks">Žymės</string>
<string name="bookmark_empty">Jūs nepridėjote jokių žymių</string>
@ -751,4 +756,18 @@
<string name="pending">Laukiama</string>
<string name="failed">Nepavyko</string>
<string name="could_not_load_place_data">Nepavyko įkelti vietos duomenų</string>
<string name="custom_selector_delete_folder">Trinti aplanką</string>
<string name="custom_selector_confirm_deletion_title">Patvirtinti ištrynimą</string>
<string name="custom_selector_confirm_deletion_message">Ar tikrai norite ištrinti aplanką %1$s, kuriame yra %2$d elementų?</string>
<string name="custom_selector_delete">Ištrinti</string>
<string name="custom_selector_cancel">Atšaukti</string>
<string name="custom_selector_folder_deleted_success">Aplankas %1$s sėkmingai ištrintas</string>
<string name="custom_selector_folder_deleted_failure">Nepavyko ištrinti aplanko %1$s</string>
<string name="custom_selector_error_trashing_folder_contents">Klaida išsiunčiant į šiukšliadėžę aplanko turinį: %1$s</string>
<string name="error_while_loading">Įkeliant įvyko klaida</string>
<string name="no_usages_found">Naudojimo būdų nerasta</string>
<string name="usages_on_commons_heading">Vikiteka</string>
<string name="usages_on_other_wikis_heading">Kiti viki</string>
<string name="file_usages_container_heading">Failo naudojimas</string>
<string name="account">Paskyra</string>
</resources>

View file

@ -818,4 +818,6 @@
<string name="caption">Толкување</string>
<string name="caption_copied_to_clipboard">Толкувањето е ставено во меѓускладот</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Честитаме. Сите слики од овој албум се подигнати или обележани за неподигање.</string>
<string name="show_in_explore">Прикажи во „Истражи“</string>
<string name="show_in_nearby">Прикажи во „Во близина“</string>
</resources>

View file

@ -814,4 +814,6 @@
<string name="caption">Legenda</string>
<string name="caption_copied_to_clipboard">Legenda copià an sla taulëtta</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Congratulassion, tute le fòto ëd s\'àlbom a son ëstàita carià opura marcà coma da nen carié.</string>
<string name="show_in_explore">Smon-e andrinta a Explore</string>
<string name="show_in_nearby">Smon-e andrinta a Nearby</string>
</resources>

View file

@ -6,24 +6,72 @@
* Baloch Khan
* Ibrahim khashrowdi
* Saraiki
* شاه زمان پټان
-->
<resources>
<plurals name="multiple_uploads_title" fuzzy="true">
<item quantity="one">1 پورته کول</item>
<item quantity="other">%1$d پورته کول</item>
<string name="commons_github">خونديځ ګيټهوب سرچينه کوډ</string>
<string name="commons_logo">خونديځ نښان</string>
<string name="commons_website">خونديځ وېبپاڼه</string>
<string name="exit_location_picker">له ځای ټاکونکي وتل</string>
<string name="submit">سپارل</string>
<string name="add_another_description">بل سپيناوی ورزياتول</string>
<string name="add_new_contribution">نوې ونډې ورزياتول</string>
<string name="add_contribution_from_camera">د کامرې له لارې ونډه ورزياتول</string>
<string name="add_contribution_from_photos">انځورونو له لارې ونډه ورزياتول</string>
<string name="add_contribution_from_contributions_gallery">د پخوانيو ونډو له انځورتونه د ونډې ورزياتول</string>
<string name="show_captions">نيونګې</string>
<string name="row_item_language_description">ژبې سپيناوی</string>
<string name="row_item_caption">نيونګ</string>
<string name="show_captions_description">سپيناوی</string>
<string name="nearby_row_image">انځور</string>
<string name="nearby_all">ټول</string>
<string name="nearby_filter_toggle">پورته کول</string>
<string name="nearby_filter_search">لټون ليد</string>
<string name="nearby_filter_state">ځای حالت</string>
<string name="appwidget_img">ورځې انځور</string>
<plurals name="uploads_pending_notification_indicator">
<item quantity="one">%1$d دوتنه پورته کول</item>
<item quantity="other"> %1$d دوتنې پورته کول</item>
</plurals>
<plurals name="contributions_subtitle">
<item quantity="one">(%1$d)</item>
<item quantity="other">(%1$d)</item>
</plurals>
<string name="starting_uploads">پورته کولو پيل</string>
<plurals name="starting_multiple_uploads">
<item quantity="one">جريان %d پورته کول</item>
<item quantity="other">پورته کولو %d جريان</item>
</plurals>
<plurals name="multiple_uploads_title">
<item quantity="one">%d upload</item>
<item quantity="other">%d پورته کول</item>
</plurals>
<string name="share_license_summary" fuzzy="true">دا انځور به د %1$s په منښتليک سمبال وي.</string>
<string name="navigation_item_explore">سپړنه</string>
<string name="preference_category_appearance">ښکارېدنه</string>
<string name="preference_category_general">ټولګړی</string>
<string name="preference_category_feedback">غبرګون</string>
<string name="preference_category_privacy">پټنتيا</string>
<string name="app_name">ويکي خونديځ</string>
<string name="menu_settings">امستنې</string>
<string name="username">کارن-نوم</string>
<string name="intent_share_upload_label">خونديځ ته راپورته کول</string>
<string name="upload_in_progress">راپورته کول جريان لري</string>
<string name="username">کارن‌نوم</string>
<string name="password">پټنوم</string>
<string name="login_credential">خپل خونديځ بېټا ګڼون ته ورننوځئ</string>
<string name="login">ننوتل</string>
<string name="forgot_password">پټنوم مو هېر شوی؟</string>
<string name="signup">نومليکنه</string>
<string name="logging_in_title">په ننوتلو کې دی</string>
<string name="logging_in_message">لطفاً تم شۍ …</string>
<string name="login_success" fuzzy="true">غونډال کې بريالی ورننوتلۍ!</string>
<string name="login_failed" fuzzy="true">غونډال کې ننوتنه نابريالې شوه!</string>
<string name="updating_caption_title">نيونګې او سپيناوي تازه کول</string>
<string name="updating_caption_message">په تمه اوسئ</string>
<string name="login_success">بريالی ننوتون</string>
<string name="login_failed">ناسم ننوتون</string>
<string name="upload_failed">دوتنه و نه موندل شوه. لطفاً د يوې بلې دوتنې د موندلو هڅه وکړئ.</string>
<string name="retry_limit_reached">د بياځلي هڅې وروستۍ اندازه پوره شوه! مهرباني وکړئ، لغوه يې کړئ او بيا د راپورته کولو هڅه وکړئ</string>
<string name="unrestricted_battery_mode">بيټري سمون بندول؟</string>
<string name="suggest_unrestricted_mode">کله چې د بیټرۍ اصلاح بنده وي، له ۳ څخه زیاتو عکسونو اپلوډ کول ډیر باوري کار کوي. مهرباني وکړئ د اسانه اپلوډ تجربې لپاره د کامنز ایپ لپاره د ترتیباتو څخه د بیټرۍ اصلاح بند کړئ. \n\n د بیټرۍ اصلاح بندولو لپاره ممکنه ګامونه:\n\n لومړی ګام: لاندې \'ترتیبات\' تڼۍ باندې کلیک وکړئ.\n\n دوهم ګام: له \'نه غوره شوی\' څخه \'ټول ایپس\' ته واړوئ.\n\n دریم ګام: د \"کامن\" یا \"fr.free.nrw.commons\" لټون وکړئ.\n\n څلورم ګام: دا کلیک کړئ او \'غوره نه کړئ\' غوره کړئ.\n\n پنځم ګام: \'بشپړ شوی\' فشار ورکړئ.</string>
<string name="uploading_started">پورته کېدنه پيل شوه!</string>
<string name="upload_completed_notification_title">%1$s پورته شوی!</string>
<string name="upload_progress_notification_title_in_progress">د %1$s پورته کول</string>
@ -88,4 +136,5 @@
<string name="navigation_item_settings">امستنې</string>
<string name="navigation_item_feedback">غبرگون</string>
<string name="navigation_item_logout">وتل</string>
<string name="account">گڼون</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Authors:
* A100Star
* Ajeje Brazorf
* Amire80
* Cabal
@ -26,6 +27,7 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="all">
<string name="submit">{{Identical|Submit}}</string>
<string name="nearby_all">{{identical|All}}</string>
<string name="nearby_filter_toggle">יש קצת בלבול בתרגום עם להחליף למעלה, למרות שהתרגום באופן טכני נכון, המשמעות באנגלית היא החלפה בין מצבים (במקרה הזה, החלפת מצב פעיל) אז אפשר להשתמש בהפעלה כתרגום</string>
<string name="uploads_pending_notification_indicator">Status text about number of uploads left.\n* %1$d represents number of uploads left, including current one</string>
<string name="contributions_subtitle">See the current issue [https://phabricator.wikimedia.org/T267142 T267142] tracked in Phabricator about the &lt;code&gt;&lt;nowiki&gt;|zero=&lt;/nowiki&gt;&lt;/code&gt; option currently not supported on Translatewiki.net with the custom &lt;code&gt;&lt;nowiki&gt;{{PLURAL}}&lt;/nowiki&gt;&lt;/code&gt; rules used by this project for Android, using a non-MediaWiki syntax.</string>
<string name="multiple_uploads_title">{{Identical|Upload}}</string>

View file

@ -333,6 +333,7 @@
<string name="copy_wikicode">Копирование викикода в буфер обмена</string>
<string name="wikicode_copied">Викикод скопирован в буфер обмена</string>
<string name="nearby_location_not_available">Функция «Поблизости» может работать некорректно, определение местоположения недоступно.</string>
<string name="nearby_showing_pins_offline">Интернет недоступен. Показаны только кэшированные места.</string>
<string name="upload_location_access_denied">Доступ к местоположению запрещён. Чтобы использовать эту функцию, укажите своё местоположение вручную.</string>
<string name="location_permission_rationale_nearby">Необходимо разрешение для отображения списка мест поблизости</string>
<string name="location_permission_rationale_explore">Необходимо разрешение для отображения списка мест поблизости</string>
@ -869,7 +870,9 @@
<string name="usages_on_commons_heading">Викисклад</string>
<string name="usages_on_other_wikis_heading">Другие вики</string>
<string name="file_usages_container_heading">Использование файла</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</string>
<string name="account">Учётная запись</string>
<string name="caption">Подпись</string>
<string name="caption_copied_to_clipboard">Подпись скопирована в буфер обмена</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Поздравляем, все фотографии в этом альбоме либо загружены, либо помечены как не предназначенные для загрузки.</string>
</resources>

View file

@ -820,4 +820,5 @@
<string name="account_vanish_request_confirm">Att få kontot att försvinna är en &lt;b&gt;sista utväg&lt;/b&gt; och bör &lt;b&gt;endast användas när du vill sluta redigera för alltid&lt;/b&gt; och även dölja så många av dina tidigare associationer som möjligt.&lt;br/&gt;&lt;br/&gt;Konton raderas på Wikimedia Commons genom att ändra kontonamnet för att göra så att andra inte kan känna igen bidragen i en process som kallas kontoförsvinnande. &lt;b&gt;Försvinnande garanterar inte fullständig anonymitet eller att bidrag tas bort från projekten&lt;/b&gt;.</string>
<string name="caption">Bildtext</string>
<string name="caption_copied_to_clipboard">Bildtext kopierades till urklipp</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Grattis! Alla bilder i detta album har antingen laddats upp eller markerats för att inte laddas upp.</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Authors:
* August.C
* DinoWP
* Diskdance
* GuoPC
@ -831,4 +832,14 @@
<string name="usages_on_commons_heading">維基共享資源</string>
<string name="usages_on_other_wikis_heading">其它 wiki</string>
<string name="file_usages_container_heading">檔案用途</string>
<string name="title_activity_single_web_view">單一WebViewActivity</string>
<string name="account">帳號</string>
<string name="vanish_account">引退帳號</string>
<string name="account_vanish_request_confirm_title">隱退帳號警告</string>
<string name="account_vanish_request_confirm">隱退是最後手段,並且應該僅在您希望永遠不再編輯時使用,以及盡可能隱藏您過去關聯蹤跡下使用。帳號刪除是透過更改您的帳號名稱來完成的,這樣其他人就無法在稱之為帳號隱退的過程中辨識出您的貢獻。另外,隱退並不能保證能完全匿名或移除對專案的貢獻。\n\n要繼續隱退流程請完成以下的帳號隱退表單。</string>
<string name="caption">說明</string>
<string name="caption_copied_to_clipboard">文字已複製至剪貼簿</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">恭喜,該相簿中的所有圖片已上傳或標記為不可上傳。</string>
<string name="show_in_explore">在探索中顯示</string>
<string name="show_in_nearby">顯示在附近</string>
</resources>

View file

@ -18,6 +18,7 @@
* Huajing
* Hydra
* Ken418
* Kichin
* Kuailong
* Lantianjialiang
* LittlePaw365
@ -870,4 +871,6 @@
<string name="caption">说明</string>
<string name="caption_copied_to_clipboard">已复制到剪贴板</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">恭喜,专辑中的所有图片都已上传或标记为不上传。</string>
<string name="show_in_explore">在探索中显示</string>
<string name="show_in_nearby">显示在附近</string>
</resources>

View file

@ -870,4 +870,6 @@ Upload your first media by tapping on the add button.</string>
<string name="caption_copied_to_clipboard">Caption copied to clipboard</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">Congratulations, all pictures in this album have been either uploaded or marked as not for upload.</string>
<string name="show_in_explore">Show in Explore</string>
<string name="show_in_nearby">Show in Nearby</string>
</resources>

View file

@ -11,6 +11,7 @@ import fr.free.nrw.commons.upload.GpsCategoryModel
import io.reactivex.Single
import io.reactivex.subjects.BehaviorSubject
import media
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentMatchers
@ -331,4 +332,42 @@ class CategoriesModelTest {
media(),
)
}
@Test
fun `test valid input with XXXX in it between the expected range 20XX`() {
val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014)")
Assert.assertFalse(input)
}
@Test
fun `test valid input with XXXXs in it between the expected range 20XXs`() {
val input = categoriesModel.isSpammyCategory("Amavenita (ship, 2014s)")
Assert.assertFalse(input)
}
@Test
fun `test invalid category when have needing in the input`() {
val input = categoriesModel.isSpammyCategory("Media needing categories as of 30 March 2017")
Assert.assertTrue(input)
}
@Test
fun `test invalid category when have taken on in the input`() {
val input = categoriesModel.isSpammyCategory("Photographs taken on 2015-12-08")
Assert.assertTrue(input)
}
@Test
fun `test invalid category when have yy mm or yy mm dd in the input`() {
// filtering based on [., /, -] separators between the dates.
val input = categoriesModel.isSpammyCategory("Image class 09.14")
Assert.assertTrue(input)
}
@Test
fun `test invalid category when have years not in 20XX range`() {
val input = categoriesModel.isSpammyCategory("Japan in the 1400s")
Assert.assertTrue(input)
}
}

View file

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

View file

@ -89,7 +89,7 @@ class ContributionsListFragmentUnitTests {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.rvContributionsList = mock()
fragment.scrollToTop()
verify(fragment.rvContributionsList).smoothScrollToPosition(0)
verify(fragment.rvContributionsList)?.smoothScrollToPosition(0)
}
@Test

View file

@ -448,7 +448,7 @@ class MainActivityUnitTests {
fun testOnSetUpPagerNearBy() {
val item = Mockito.mock(MenuItem::class.java)
`when`(item.title).thenReturn(activity.getString(R.string.nearby_fragment))
activity.navListener.onNavigationItemSelected(item)
activity.navListener?.onNavigationItemSelected(item)
verify(item, Mockito.times(3)).title
verify(applicationKvStore, Mockito.times(1))
.putBoolean("last_opened_nearby", true)
@ -459,7 +459,7 @@ class MainActivityUnitTests {
fun testOnSetUpPagerOtherThanNearBy() {
val item = Mockito.mock(MenuItem::class.java)
`when`(item.title).thenReturn(activity.getString(R.string.bookmarks))
activity.navListener.onNavigationItemSelected(item)
activity.navListener?.onNavigationItemSelected(item)
verify(item, Mockito.times(3)).title
verify(applicationKvStore, Mockito.times(1))
.putBoolean("last_opened_nearby", false)

View file

@ -11,16 +11,19 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.test.core.app.ApplicationProvider
import com.google.android.material.tabs.TabLayout
import com.nhaarman.mockitokotlin2.eq
import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.createTestClient
import org.junit.Assert
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
@ -34,6 +37,7 @@ import org.robolectric.annotation.LooperMode
import org.robolectric.fakes.RoboMenu
import org.robolectric.fakes.RoboMenuItem
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [21], application = TestCommonsApplication::class)
@LooperMode(LooperMode.Mode.PAUSED)
@ -151,6 +155,14 @@ class ExploreFragmentUnitTest {
Shadows.shadowOf(getMainLooper()).idle()
val menu: Menu = RoboMenu(context)
fragment.onCreateOptionsMenu(menu, inflater)
verify(inflater).inflate(R.menu.menu_search, menu)
val captor = ArgumentCaptor.forClass(
Int::class.java
)
verify(inflater).inflate(captor.capture(), eq(menu))
val capturedLayout = captor.value
assertTrue(capturedLayout == R.menu.menu_search || capturedLayout == R.menu.explore_fragment_menu)
}
}

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons.upload
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.media.MediaClient
@ -18,6 +20,7 @@ import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import java.io.FileInputStream
import java.io.InputStream
class ImageProcessingServiceTest {
@Mock
@ -38,6 +41,9 @@ class ImageProcessingServiceTest {
@Mock
internal var location: LatLng? = null
@Mock
internal lateinit var appContext: Context
@InjectMocks
var imageProcessingService: ImageProcessingService? = null
@ -49,8 +55,10 @@ class ImageProcessingServiceTest {
fun setUp() {
MockitoAnnotations.openMocks(this)
val mediaUri = mock(Uri::class.java)
val contentUri = mock(Uri::class.java)
val mockPlace = mock(Place::class.java)
val mockTitle = mock(List::class.java)
val contentResolver = mock(ContentResolver::class.java)
`when`(mockPlace.wikiDataEntityId).thenReturn("Q1")
`when`(mockPlace.getLocation()).thenReturn(mock(LatLng::class.java))
@ -59,6 +67,8 @@ class ImageProcessingServiceTest {
`when`(mockTitle.isSet).thenReturn(true)*/
`when`(uploadItem.mediaUri).thenReturn(mediaUri)
`when`(uploadItem.contentUri).thenReturn(contentUri)
`when`(appContext.contentResolver).thenReturn(contentResolver)
`when`(uploadItem.imageQuality).thenReturn(ImageUtils.IMAGE_WAIT)
`when`(uploadItem.uploadMediaDetails).thenReturn(mockTitle as MutableList<UploadMediaDetail>?)
@ -68,7 +78,9 @@ class ImageProcessingServiceTest {
`when`(fileUtilsWrapper!!.getFileInputStream(ArgumentMatchers.anyString()))
.thenReturn(mock(FileInputStream::class.java))
`when`(fileUtilsWrapper!!.getSHA1(any(FileInputStream::class.java)))
`when`(appContext.contentResolver.openInputStream(ArgumentMatchers.any()))
.thenReturn(mock(InputStream::class.java))
`when`(fileUtilsWrapper!!.getSHA1(any(InputStream::class.java)))
.thenReturn("fileSha")
`when`(fileUtilsWrapper!!.getGeolocationOfFile(ArgumentMatchers.anyString(), any(LatLng::class.java)))