Merge branch 'main' into fix-multiupload

This commit is contained in:
Rohit Verma 2025-02-25 19:29:14 +05:30 committed by GitHub
commit af2d0d8cdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 8635 additions and 7213 deletions

View file

@ -0,0 +1,96 @@
name: Android CI Comment
on:
workflow_run:
workflows: ["Android CI"]
types: [completed]
branches: [main]
permissions:
issues: write
jobs:
comment:
name: Comment on PR with APK links
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout base branch
uses: actions/checkout@v3
with:
ref: ${{ github.event.workflow_run.head_branch }}
- name: Download Run ID Artifact
uses: actions/download-artifact@v4
with:
name: run-id
run-id: ${{ github.event.workflow_run.id }}
- name: Read Run ID
id: read-run-id
run: echo "RUN_ID=$(cat run_id.txt)" >> $GITHUB_ENV
- name: Comment on PR with APK download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v6
with:
script: |
try {
const token = process.env.GH_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN is not set.');
}
const runId = "${{ env.RUN_ID }}";
if (!runId) {
throw new Error('Run ID not found.');
}
// Get the PR number from the workflow_run event
const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }};
if (!prNumber) {
console.log('No PR number found in workflow_run event.');
return;
}
const { data: { artifacts } } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: 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/${runId}/artifacts/${betaArtifact.id}`;
const prodDownloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/suites/${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: prNumber,
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);
core.setFailed(`Workflow failed: ${error.message}`);
}

View file

@ -1,6 +1,10 @@
name: Android CI
on: [push, pull_request, workflow_dispatch]
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
actions: read
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
@ -89,7 +93,7 @@ jobs:
run: bash ./gradlew assembleBetaDebug --stacktrace
- name: Upload betaDebug APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: betaDebugAPK
path: app/build/outputs/apk/beta/debug/app-*.apk
@ -98,7 +102,18 @@ jobs:
run: bash ./gradlew assembleProdDebug --stacktrace
- name: Upload prodDebug APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: prodDebugAPK
path: app/build/outputs/apk/prod/debug/app-*.apk
- name: Store Workflow Run ID
if: github.event_name == 'pull_request'
run: echo "${{ github.run_id }}" > run_id.txt
- name: Upload Run ID as Artifact
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: run-id
path: run_id.txt

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

3
.gitignore vendored
View file

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

View file

@ -1,6 +1,6 @@
# Wikimedia Commons Android app
![GitHub issue custom search](https://img.shields.io/github/issues-search?label=%22good%20first%20issue%22%20issues&query=repo%3Acommons-app%2Fapps-android-commons%20is%3Aissue%20is%3Aopen%20label%3A%22good%20first%20issue%22)
[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=master)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amaster)
[![Build status](https://github.com/commons-app/apps-android-commons/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/commons-app/apps-android-commons/actions?query=branch%3Amain)
[![Preview the app](https://img.shields.io/badge/Preview-Appetize.io-orange.svg)](https://appetize.io/app/8ywtpe9f8tb8h6bey11c92vkcw)
[![codecov](https://codecov.io/gh/commons-app/apps-android-commons/branch/master/graph/badge.svg)](https://codecov.io/gh/commons-app/apps-android-commons)
@ -45,7 +45,7 @@ This software is open source, licensed under the [Apache License 2.0][10].
[1]: https://play.google.com/store/apps/details?id=fr.free.nrw.commons
[2]: https://commons-app.github.io/
[3]: https://github.com/commons-app/apps-android-commons/issues
[3]: https://github.com/commons-app/apps-android-commons/issues?q=is%3Aopen+is%3Aissue+no%3Aassignee+-label%3Adebated+label%3Abug+-label%3A%22low+priority%22+-label%3Aupstream
[4]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-android-documentation
[5]: https://github.com/commons-app/commons-app-documentation/blob/master/android/README.md#-user-documentation

View file

@ -175,8 +175,8 @@ dependencies {
testImplementation "androidx.work:work-testing:$work_version"
//Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
kaptTest "androidx.databinding:databinding-compiler:8.0.2"
kaptAndroidTest "androidx.databinding:databinding-compiler:8.0.2"

View file

@ -232,12 +232,6 @@
android:exported="false"
android:label="@string/provider_bookmarks"
android:syncable="false" />
<provider
android:name=".bookmarks.locations.BookmarkLocationsContentProvider"
android:authorities="${applicationId}.bookmarks.locations.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<provider
android:name=".bookmarks.items.BookmarkItemsContentProvider"
android:authorities="${applicationId}.bookmarks.items.contentprovider"

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

@ -247,13 +247,17 @@ class CommonsApplication : MultiDexApplication() {
DBOpenHelper.CONTRIBUTIONS_TABLE
) //Delete the contributions table in the existing db on older versions
dbOpenHelper.deleteTable(
db,
DBOpenHelper.BOOKMARKS_LOCATIONS
)
try {
contributionDao.deleteAll()
} catch (e: SQLiteException) {
Timber.e(e)
}
BookmarkPicturesDao.Table.onDelete(db)
BookmarkLocationsDao.Table.onDelete(db)
BookmarkItemsDao.Table.onDelete(db)
}

View file

@ -90,6 +90,41 @@ class Media constructor(
captions = captions,
)
constructor(
captions: Map<String, String>,
categories: List<String>?,
filename: String?,
fallbackDescription: String?,
author: String?,
user: String?,
dateUploaded: Date? = Date(),
license: String? = null,
licenseUrl: String? = null,
imageUrl: String? = null,
thumbUrl: String? = null,
coordinates: LatLng? = null,
descriptions: Map<String, String> = emptyMap(),
depictionIds: List<String> = emptyList(),
categoriesHiddenStatus: Map<String, Boolean> = emptyMap()
) : this(
pageId = UUID.randomUUID().toString(),
filename = filename,
fallbackDescription = fallbackDescription,
dateUploaded = dateUploaded,
author = author,
user = user,
categories = categories,
captions = captions,
license = license,
licenseUrl = licenseUrl,
imageUrl = imageUrl,
thumbUrl = thumbUrl,
coordinates = coordinates,
descriptions = descriptions,
depictionIds = depictionIds,
categoriesHiddenStatus = categoriesHiddenStatus
)
/**
* Gets media display title
* @return Media title

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

@ -1,119 +0,0 @@
package fr.free.nrw.commons.bookmarks.locations;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
// We can get uri using java.Net.Uri, but andoid implimentation is faster (but it's forgiving with handling exceptions though)
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import javax.inject.Inject;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
import timber.log.Timber;
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.COLUMN_NAME;
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao.Table.TABLE_NAME;
/**
* Handles private storage for Bookmark locations
*/
public class BookmarkLocationsContentProvider extends CommonsDaggerContentProvider {
private static final String BASE_PATH = "bookmarksLocations";
public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY + "/" + BASE_PATH);
/**
* Append bookmark locations name to the base uri
*/
public static Uri uriForName(String name) {
return Uri.parse(BASE_URI.toString() + "/" + name);
}
@Inject DBOpenHelper dbOpenHelper;
@Override
public String getType(@NonNull Uri uri) {
return null;
}
/**
* Queries the SQLite database for the bookmark locations
* @param uri : contains the uri for bookmark locations
* @param projection
* @param selection : handles Where
* @param selectionArgs : the condition of Where clause
* @param sortOrder : ascending or descending
*/
@SuppressWarnings("ConstantConditions")
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(TABLE_NAME);
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
/**
* Handles the update query of local SQLite Database
* @param uri : contains the uri for bookmark locations
* @param contentValues : new values to be entered to db
* @param selection : handles Where
* @param selectionArgs : the condition of Where clause
*/
@SuppressWarnings("ConstantConditions")
@Override
public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
String[] selectionArgs) {
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
int rowsUpdated;
if (TextUtils.isEmpty(selection)) {
int id = Integer.valueOf(uri.getLastPathSegment());
rowsUpdated = sqlDB.update(TABLE_NAME,
contentValues,
COLUMN_NAME + " = ?",
new String[]{String.valueOf(id)});
} else {
throw new IllegalArgumentException(
"Parameter `selection` should be empty when updating an ID");
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsUpdated;
}
/**
* Handles the insertion of new bookmark locations record to local SQLite Database
*/
@SuppressWarnings("ConstantConditions")
@Override
public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
long id = sqlDB.insert(BookmarkLocationsDao.Table.TABLE_NAME, null, contentValues);
getContext().getContentResolver().notifyChange(uri, null);
return Uri.parse(BASE_URI + "/" + id);
}
@SuppressWarnings("ConstantConditions")
@Override
public int delete(@NonNull Uri uri, String s, String[] strings) {
int rows;
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Timber.d("Deleting bookmark name %s", uri.getLastPathSegment());
rows = db.delete(TABLE_NAME,
"location_name = ?",
new String[]{uri.getLastPathSegment()}
);
getContext().getContentResolver().notifyChange(uri, null);
return rows;
}
}

View file

@ -1,26 +0,0 @@
package fr.free.nrw.commons.bookmarks.locations;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.nearby.Place;
@Singleton
public class BookmarkLocationsController {
@Inject
BookmarkLocationsDao bookmarkLocationDao;
@Inject
public BookmarkLocationsController() {}
/**
* Load from DB the bookmarked locations
* @return a list of Place objects.
*/
public List<Place> loadFavoritesLocations() {
return bookmarkLocationDao.getAllBookmarksLocations();
}
}

View file

@ -0,0 +1,20 @@
package fr.free.nrw.commons.bookmarks.locations
import fr.free.nrw.commons.nearby.Place
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BookmarkLocationsController @Inject constructor(
private val bookmarkLocationDao: BookmarkLocationsDao
) {
/**
* Load bookmarked locations from the database.
* @return a list of Place objects.
*/
suspend fun loadFavoritesLocations(): List<Place> =
bookmarkLocationDao.getAllBookmarksLocationsPlace()
}

View file

@ -1,313 +0,0 @@
package fr.free.nrw.commons.bookmarks.locations;
import android.annotation.SuppressLint;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.RemoteException;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.nearby.NearbyController;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Label;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.Sitelinks;
import timber.log.Timber;
import static fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider.BASE_URI;
public class BookmarkLocationsDao {
private final Provider<ContentProviderClient> clientProvider;
@Inject
public BookmarkLocationsDao(@Named("bookmarksLocation") Provider<ContentProviderClient> clientProvider) {
this.clientProvider = clientProvider;
}
/**
* Find all persisted locations bookmarks on database
*
* @return list of Place
*/
@NonNull
public List<Place> getAllBookmarksLocations() {
List<Place> items = new ArrayList<>();
Cursor cursor = null;
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
BookmarkLocationsContentProvider.BASE_URI,
Table.ALL_FIELDS,
null,
new String[]{},
null);
while (cursor != null && cursor.moveToNext()) {
items.add(fromCursor(cursor));
}
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
if (cursor != null) {
cursor.close();
}
db.release();
}
return items;
}
/**
* Look for a place in bookmarks table in order to insert or delete it
*
* @param bookmarkLocation : Place object
* @return is Place now fav ?
*/
public boolean updateBookmarkLocation(Place bookmarkLocation) {
boolean bookmarkExists = findBookmarkLocation(bookmarkLocation);
if (bookmarkExists) {
deleteBookmarkLocation(bookmarkLocation);
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, false);
} else {
addBookmarkLocation(bookmarkLocation);
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, true);
}
return !bookmarkExists;
}
/**
* Add a Place to bookmarks table
*
* @param bookmarkLocation : Place to add
*/
private void addBookmarkLocation(Place bookmarkLocation) {
ContentProviderClient db = clientProvider.get();
try {
db.insert(BASE_URI, toContentValues(bookmarkLocation));
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
/**
* Delete a Place from bookmarks table
*
* @param bookmarkLocation : Place to delete
*/
private void deleteBookmarkLocation(Place bookmarkLocation) {
ContentProviderClient db = clientProvider.get();
try {
db.delete(BookmarkLocationsContentProvider.uriForName(bookmarkLocation.name), null, null);
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
/**
* Find a Place from database based on its name
*
* @param bookmarkLocation : Place to find
* @return boolean : is Place in database ?
*/
public boolean findBookmarkLocation(Place bookmarkLocation) {
Cursor cursor = null;
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
BookmarkLocationsContentProvider.BASE_URI,
Table.ALL_FIELDS,
Table.COLUMN_NAME + "=?",
new String[]{bookmarkLocation.name},
null);
if (cursor != null && cursor.moveToFirst()) {
return true;
}
} catch (RemoteException e) {
// This feels lazy, but to hell with checked exceptions. :)
throw new RuntimeException(e);
} finally {
if (cursor != null) {
cursor.close();
}
db.release();
}
return false;
}
@SuppressLint("Range")
@NonNull
Place fromCursor(final Cursor cursor) {
final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)),
cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LONG)), 1F);
final Sitelinks.Builder builder = new Sitelinks.Builder();
builder.setWikipediaLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIPEDIA_LINK)));
builder.setWikidataLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_WIKIDATA_LINK)));
builder.setCommonsLink(cursor.getString(cursor.getColumnIndex(Table.COLUMN_COMMONS_LINK)));
return new Place(
cursor.getString(cursor.getColumnIndex(Table.COLUMN_LANGUAGE)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
Label.fromText((cursor.getString(cursor.getColumnIndex(Table.COLUMN_LABEL_TEXT)))),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
location,
cursor.getString(cursor.getColumnIndex(Table.COLUMN_CATEGORY)),
builder.build(),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_PIC)),
Boolean.parseBoolean(cursor.getString(cursor.getColumnIndex(Table.COLUMN_EXISTS)))
);
}
private ContentValues toContentValues(Place bookmarkLocation) {
ContentValues cv = new ContentValues();
cv.put(BookmarkLocationsDao.Table.COLUMN_NAME, bookmarkLocation.getName());
cv.put(BookmarkLocationsDao.Table.COLUMN_LANGUAGE, bookmarkLocation.getLanguage());
cv.put(BookmarkLocationsDao.Table.COLUMN_DESCRIPTION, bookmarkLocation.getLongDescription());
cv.put(BookmarkLocationsDao.Table.COLUMN_CATEGORY, bookmarkLocation.getCategory());
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_TEXT, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getText() : "");
cv.put(BookmarkLocationsDao.Table.COLUMN_LABEL_ICON, bookmarkLocation.getLabel()!=null ? bookmarkLocation.getLabel().getIcon() : null);
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIPEDIA_LINK, bookmarkLocation.siteLinks.getWikipediaLink().toString());
cv.put(BookmarkLocationsDao.Table.COLUMN_WIKIDATA_LINK, bookmarkLocation.siteLinks.getWikidataLink().toString());
cv.put(BookmarkLocationsDao.Table.COLUMN_COMMONS_LINK, bookmarkLocation.siteLinks.getCommonsLink().toString());
cv.put(BookmarkLocationsDao.Table.COLUMN_LAT, bookmarkLocation.location.getLatitude());
cv.put(BookmarkLocationsDao.Table.COLUMN_LONG, bookmarkLocation.location.getLongitude());
cv.put(BookmarkLocationsDao.Table.COLUMN_PIC, bookmarkLocation.pic);
cv.put(BookmarkLocationsDao.Table.COLUMN_EXISTS, bookmarkLocation.exists.toString());
return cv;
}
public static class Table {
public static final String TABLE_NAME = "bookmarksLocations";
static final String COLUMN_NAME = "location_name";
static final String COLUMN_LANGUAGE = "location_language";
static final String COLUMN_DESCRIPTION = "location_description";
static final String COLUMN_LAT = "location_lat";
static final String COLUMN_LONG = "location_long";
static final String COLUMN_CATEGORY = "location_category";
static final String COLUMN_LABEL_TEXT = "location_label_text";
static final String COLUMN_LABEL_ICON = "location_label_icon";
static final String COLUMN_IMAGE_URL = "location_image_url";
static final String COLUMN_WIKIPEDIA_LINK = "location_wikipedia_link";
static final String COLUMN_WIKIDATA_LINK = "location_wikidata_link";
static final String COLUMN_COMMONS_LINK = "location_commons_link";
static final String COLUMN_PIC = "location_pic";
static final String COLUMN_EXISTS = "location_exists";
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
public static final String[] ALL_FIELDS = {
COLUMN_NAME,
COLUMN_LANGUAGE,
COLUMN_DESCRIPTION,
COLUMN_CATEGORY,
COLUMN_LABEL_TEXT,
COLUMN_LABEL_ICON,
COLUMN_LAT,
COLUMN_LONG,
COLUMN_IMAGE_URL,
COLUMN_WIKIPEDIA_LINK,
COLUMN_WIKIDATA_LINK,
COLUMN_COMMONS_LINK,
COLUMN_PIC,
COLUMN_EXISTS,
};
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ COLUMN_NAME + " STRING PRIMARY KEY,"
+ COLUMN_LANGUAGE + " STRING,"
+ COLUMN_DESCRIPTION + " STRING,"
+ COLUMN_CATEGORY + " STRING,"
+ COLUMN_LABEL_TEXT + " STRING,"
+ COLUMN_LABEL_ICON + " INTEGER,"
+ COLUMN_LAT + " DOUBLE,"
+ COLUMN_LONG + " DOUBLE,"
+ COLUMN_IMAGE_URL + " STRING,"
+ COLUMN_WIKIPEDIA_LINK + " STRING,"
+ COLUMN_WIKIDATA_LINK + " STRING,"
+ COLUMN_COMMONS_LINK + " STRING,"
+ COLUMN_PIC + " STRING,"
+ COLUMN_EXISTS + " STRING"
+ ");";
public static void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
}
public static void onDelete(SQLiteDatabase db) {
db.execSQL(DROP_TABLE_STATEMENT);
onCreate(db);
}
public static void onUpdate(final SQLiteDatabase db, int from, final int to) {
Timber.d("bookmarksLocations db is updated from:"+from+", to:"+to);
if (from == to) {
return;
}
if (from < 7) {
// doesn't exist yet
from++;
onUpdate(db, from, to);
return;
}
if (from == 7) {
// table added in version 8
onCreate(db);
from++;
onUpdate(db, from, to);
return;
}
if (from < 10) {
from++;
onUpdate(db, from, to);
return;
}
if (from == 10) {
//This is safe, and can be called clean, as we/I do not remember the appropriate version for this
//We are anyways switching to room, these things won't be necessary then
try {
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_pic STRING;");
}catch (SQLiteException exception){
Timber.e(exception);//
}
return;
}
if (from >= 12) {
try {
db.execSQL(
"ALTER TABLE bookmarksLocations ADD COLUMN location_destroyed STRING;");
} catch (SQLiteException exception) {
Timber.e(exception);
}
}
if (from >= 13){
try {
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_language STRING;");
} catch (SQLiteException exception){
Timber.e(exception);
}
}
if (from >= 14){
try {
db.execSQL("ALTER TABLE bookmarksLocations ADD COLUMN location_exists STRING;");
} catch (SQLiteException exception){
Timber.e(exception);
}
}
}
}
}

View file

@ -0,0 +1,65 @@
package fr.free.nrw.commons.bookmarks.locations
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import fr.free.nrw.commons.nearby.NearbyController
import fr.free.nrw.commons.nearby.Place
/**
* DAO for managing bookmark locations in the database.
*/
@Dao
abstract class BookmarkLocationsDao {
/**
* Adds or updates a bookmark location in the database.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun addBookmarkLocation(bookmarkLocation: BookmarksLocations)
/**
* Fetches all bookmark locations from the database.
*/
@Query("SELECT * FROM bookmarks_locations")
abstract suspend fun getAllBookmarksLocations(): List<BookmarksLocations>
/**
* Checks if a bookmark location exists by name.
*/
@Query("SELECT EXISTS (SELECT 1 FROM bookmarks_locations WHERE location_name = :name)")
abstract suspend fun findBookmarkLocation(name: String): Boolean
/**
* Deletes a bookmark location from the database.
*/
@Delete
abstract suspend fun deleteBookmarkLocation(bookmarkLocation: BookmarksLocations)
/**
* Adds or removes a bookmark location and updates markers.
* @return `true` if added, `false` if removed.
*/
suspend fun updateBookmarkLocation(bookmarkLocation: Place): Boolean {
val exists = findBookmarkLocation(bookmarkLocation.name)
if (exists) {
deleteBookmarkLocation(bookmarkLocation.toBookmarksLocations())
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, false)
} else {
addBookmarkLocation(bookmarkLocation.toBookmarksLocations())
NearbyController.updateMarkerLabelListBookmark(bookmarkLocation, true)
}
return !exists
}
/**
* Fetches all bookmark locations as `Place` objects.
*/
suspend fun getAllBookmarksLocationsPlace(): List<Place> {
return getAllBookmarksLocations().map { it.toPlace() }
}
}

View file

@ -1,137 +0,0 @@
package fr.free.nrw.commons.bookmarks.locations;
import android.Manifest.permission;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import dagger.android.support.DaggerFragment;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.ContributionController;
import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions;
import fr.free.nrw.commons.nearby.fragments.PlaceAdapter;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import kotlin.Unit;
public class BookmarkLocationsFragment extends DaggerFragment {
public FragmentBookmarksLocationsBinding binding;
@Inject BookmarkLocationsController controller;
@Inject ContributionController contributionController;
@Inject BookmarkLocationsDao bookmarkLocationDao;
@Inject CommonPlaceClickActions commonPlaceClickActions;
private PlaceAdapter adapter;
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = 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) {
contributionController.locationPermissionCallback.onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
} else {
contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied));
}
}
}
});
/**
* Create an instance of the fragment with the right bundle parameters
* @return an instance of the fragment
*/
public static BookmarkLocationsFragment newInstance() {
return new BookmarkLocationsFragment();
}
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
ViewGroup container,
Bundle savedInstanceState
) {
binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
binding.loadingImagesProgressBar.setVisibility(View.VISIBLE);
binding.listView.setLayoutManager(new LinearLayoutManager(getContext()));
adapter = new PlaceAdapter(bookmarkLocationDao,
place -> Unit.INSTANCE,
(place, isBookmarked) -> {
adapter.remove(place);
return Unit.INSTANCE;
},
commonPlaceClickActions,
inAppCameraLocationPermissionLauncher,
galleryPickLauncherForResult,
cameraPickLauncherForResult
);
binding.listView.setAdapter(adapter);
}
@Override
public void onResume() {
super.onResume();
initList();
}
/**
* Initialize the recycler view with bookmarked locations
*/
private void initList() {
List<Place> places = controller.loadFavoritesLocations();
adapter.setItems(places);
binding.loadingImagesProgressBar.setVisibility(View.GONE);
if (places.size() <= 0) {
binding.statusMessage.setText(R.string.bookmark_empty);
binding.statusMessage.setVisibility(View.VISIBLE);
} else {
binding.statusMessage.setVisibility(View.GONE);
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,161 @@
package fr.free.nrw.commons.bookmarks.locations
import android.Manifest.permission
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.android.support.DaggerFragment
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.databinding.FragmentBookmarksLocationsBinding
import fr.free.nrw.commons.filepicker.FilePicker
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.fragments.CommonPlaceClickActions
import fr.free.nrw.commons.nearby.fragments.PlaceAdapter
import kotlinx.coroutines.launch
import javax.inject.Inject
class BookmarkLocationsFragment : DaggerFragment() {
private var binding: FragmentBookmarksLocationsBinding? = null
@Inject lateinit var controller: BookmarkLocationsController
@Inject lateinit var contributionController: ContributionController
@Inject lateinit var bookmarkLocationDao: BookmarkLocationsDao
@Inject lateinit var commonPlaceClickActions: CommonPlaceClickActions
private lateinit var inAppCameraLocationPermissionLauncher:
ActivityResultLauncher<Array<String>>
private lateinit var adapter: PlaceAdapter
private val cameraPickLauncherForResult =
registerForActivityResult(StartActivityForResult()) { result ->
contributionController.handleActivityResultWithCallback(
requireActivity(),
object: FilePicker.HandleActivityResult {
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
contributionController.onPictureReturnedFromCamera(
result,
requireActivity(),
callbacks
)
}
}
)
}
private val galleryPickLauncherForResult =
registerForActivityResult(StartActivityForResult()) { result ->
contributionController.handleActivityResultWithCallback(
requireActivity(),
object: FilePicker.HandleActivityResult {
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
contributionController.onPictureReturnedFromGallery(
result,
requireActivity(),
callbacks
)
}
}
)
}
companion object {
fun newInstance(): BookmarkLocationsFragment {
return BookmarkLocationsFragment()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentBookmarksLocationsBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.loadingImagesProgressBar?.visibility = View.VISIBLE
binding?.listView?.layoutManager = LinearLayoutManager(context)
inAppCameraLocationPermissionLauncher =
registerForActivityResult(RequestMultiplePermissions()) { result ->
val areAllGranted = result.values.all { it }
if (areAllGranted) {
contributionController.locationPermissionCallback?.onLocationPermissionGranted()
} else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
contributionController.handleShowRationaleFlowCameraLocation(
requireActivity(),
inAppCameraLocationPermissionLauncher,
cameraPickLauncherForResult
)
} else {
contributionController.locationPermissionCallback
?.onLocationPermissionDenied(
getString(R.string.in_app_camera_location_permission_denied)
)
}
}
}
adapter = PlaceAdapter(
bookmarkLocationDao,
lifecycleScope,
{ },
{ place, _ ->
adapter.remove(place)
},
commonPlaceClickActions,
inAppCameraLocationPermissionLauncher,
galleryPickLauncherForResult,
cameraPickLauncherForResult
)
binding?.listView?.adapter = adapter
}
override fun onResume() {
super.onResume()
initList()
}
fun initList() {
var places: List<Place>
if(view != null) {
viewLifecycleOwner.lifecycleScope.launch {
places = controller.loadFavoritesLocations()
updateUIList(places)
}
}
}
private fun updateUIList(places: List<Place>) {
adapter.items = places
binding?.loadingImagesProgressBar?.visibility = View.GONE
if (places.isEmpty()) {
binding?.statusMessage?.text = getString(R.string.bookmark_empty)
binding?.statusMessage?.visibility = View.VISIBLE
} else {
binding?.statusMessage?.visibility = View.GONE
}
}
override fun onDestroy() {
super.onDestroy()
// Make sure to null out the binding to avoid memory leaks
binding = null
}
}

View file

@ -0,0 +1,15 @@
package fr.free.nrw.commons.bookmarks.locations
import androidx.lifecycle.ViewModel
import fr.free.nrw.commons.nearby.Place
import kotlinx.coroutines.flow.Flow
class BookmarkLocationsViewModel(
private val bookmarkLocationsDao: BookmarkLocationsDao
): ViewModel() {
// fun getAllBookmarkLocations(): List<Place> {
// return bookmarkLocationsDao.getAllBookmarksLocationsPlace()
// }
}

View file

@ -0,0 +1,72 @@
package fr.free.nrw.commons.bookmarks.locations
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Label
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.Sitelinks
@Entity(tableName = "bookmarks_locations")
data class BookmarksLocations(
@PrimaryKey @ColumnInfo(name = "location_name") val locationName: String,
@ColumnInfo(name = "location_language") val locationLanguage: String,
@ColumnInfo(name = "location_description") val locationDescription: String,
@ColumnInfo(name = "location_lat") val locationLat: Double,
@ColumnInfo(name = "location_long") val locationLong: Double,
@ColumnInfo(name = "location_category") val locationCategory: String,
@ColumnInfo(name = "location_label_text") val locationLabelText: String,
@ColumnInfo(name = "location_label_icon") val locationLabelIcon: Int?,
@ColumnInfo(name = "location_image_url") val locationImageUrl: String,
@ColumnInfo(name = "location_wikipedia_link") val locationWikipediaLink: String,
@ColumnInfo(name = "location_wikidata_link") val locationWikidataLink: String,
@ColumnInfo(name = "location_commons_link") val locationCommonsLink: String,
@ColumnInfo(name = "location_pic") val locationPic: String,
@ColumnInfo(name = "location_exists") val locationExists: Boolean
)
fun BookmarksLocations.toPlace(): Place {
val location = LatLng(
locationLat,
locationLong,
1F
)
val builder = Sitelinks.Builder().apply {
setWikipediaLink(locationWikipediaLink)
setWikidataLink(locationWikidataLink)
setCommonsLink(locationCommonsLink)
}
return Place(
locationLanguage,
locationName,
Label.fromText(locationLabelText),
locationDescription,
location,
locationCategory,
builder.build(),
locationPic,
locationExists
)
}
fun Place.toBookmarksLocations(): BookmarksLocations {
return BookmarksLocations(
locationName = name,
locationLanguage = language,
locationDescription = longDescription,
locationCategory = category,
locationLat = location.latitude,
locationLong = location.longitude,
locationLabelText = label?.text ?: "",
locationLabelIcon = label?.icon,
locationImageUrl = pic,
locationWikipediaLink = siteLinks.wikipediaLink.toString(),
locationWikidataLink = siteLinks.wikidataLink.toString(),
locationCommonsLink = siteLinks.commonsLink.toString(),
locationPic = pic,
locationExists = exists
)
}

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,112 +0,0 @@
package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.DataSource;
import androidx.paging.DataSource.Factory;
import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import java.util.Collections;
import javax.inject.Inject;
import javax.inject.Named;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;
/**
* The presenter class for Contributions
*/
public class ContributionsListPresenter implements UserActionListener {
private final ContributionBoundaryCallback contributionBoundaryCallback;
private final ContributionsRepository repository;
private final Scheduler ioThreadScheduler;
private final CompositeDisposable compositeDisposable;
private final ContributionsRemoteDataSource contributionsRemoteDataSource;
LiveData<PagedList<Contribution>> contributionList;
@Inject
ContributionsListPresenter(
final ContributionBoundaryCallback contributionBoundaryCallback,
final ContributionsRemoteDataSource contributionsRemoteDataSource,
final ContributionsRepository repository,
@Named(IO_THREAD) final Scheduler ioThreadScheduler) {
this.contributionBoundaryCallback = contributionBoundaryCallback;
this.repository = repository;
this.ioThreadScheduler = ioThreadScheduler;
this.contributionsRemoteDataSource = contributionsRemoteDataSource;
compositeDisposable = new CompositeDisposable();
}
@Override
public void onAttachView(final ContributionsListContract.View view) {
}
/**
* Setup the paged list. This method sets the configuration for paged list and ties it up with
* the live data object. This method can be tweaked to update the lazy loading behavior of the
* contributions list
*/
void setup(String userName, boolean isSelf) {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
boolean shouldSetBoundaryCallback;
if (!isSelf) {
//We don't want to persist contributions for other user's, therefore
// creating a new DataSource for them
contributionsRemoteDataSource.setUserName(userName);
factory = new Factory<Integer, Contribution>() {
@NonNull
@Override
public DataSource<Integer, Contribution> create() {
return contributionsRemoteDataSource;
}
};
shouldSetBoundaryCallback = false;
} else {
contributionBoundaryCallback.setUserName(userName);
shouldSetBoundaryCallback = true;
factory = repository.fetchContributionsWithStates(
Collections.singletonList(Contribution.STATE_COMPLETED));
}
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
if (shouldSetBoundaryCallback) {
livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback);
}
contributionList = livePagedListBuilder.build();
}
@Override
public void onDetachView() {
compositeDisposable.clear();
contributionsRemoteDataSource.dispose();
contributionBoundaryCallback.dispose();
}
/**
* It is used to refresh list.
*
* @param swipeRefreshLayout used to stop refresh animation when
* refresh finishes.
*/
@Override
public void refreshList(final SwipeRefreshLayout swipeRefreshLayout) {
contributionBoundaryCallback.refreshList(() -> {
swipeRefreshLayout.setRefreshing(false);
return Unit.INSTANCE;
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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,486 +0,0 @@
package fr.free.nrw.commons.contributions;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.databinding.MainBinding;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.bookmarks.BookmarkFragment;
import fr.free.nrw.commons.explore.ExploreFragment;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.navtab.MoreBottomSheetFragment;
import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment;
import fr.free.nrw.commons.navtab.NavTab;
import fr.free.nrw.commons.navtab.NavTabLayout;
import fr.free.nrw.commons.navtab.NavTabLoggedOut;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment;
import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback;
import fr.free.nrw.commons.notification.NotificationActivity;
import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class MainActivity extends BaseActivity
implements FragmentManager.OnBackStackChangedListener {
@Inject
SessionManager sessionManager;
@Inject
ContributionController controller;
@Inject
ContributionDao contributionDao;
private ContributionsFragment contributionsFragment;
private NearbyParentFragment nearbyParentFragment;
private ExploreFragment exploreFragment;
private BookmarkFragment bookmarkFragment;
public ActiveFragment activeFragment;
private MediaDetailPagerFragment mediaDetailPagerFragment;
private NavTabLayout.OnNavigationItemSelectedListener navListener;
@Inject
public LocationServiceManager locationManager;
@Inject
NotificationController notificationController;
@Inject
QuizChecker quizChecker;
@Inject
@Named("default_preferences")
public
JsonKvStore applicationKvStore;
@Inject
ViewUtilWrapper viewUtilWrapper;
public Menu menu;
public MainBinding binding;
NavTabLayout tabLayout;
/**
* Consumers should be simply using this method to use this activity.
*
* @param context A Context of the application package implementing this class.
*/
public static void startYourself(Context context) {
Intent intent = new Intent(context, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(intent);
}
@Override
public boolean onSupportNavigateUp() {
if (activeFragment == ActiveFragment.CONTRIBUTIONS) {
if (!contributionsFragment.backButtonClicked()) {
return false;
}
} else {
onBackPressed();
showTabs();
}
return true;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = MainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarBinding.toolbar);
tabLayout = binding.fragmentMainNavTabLayout;
loadLocale();
binding.toolbarBinding.toolbar.setNavigationOnClickListener(view -> {
onSupportNavigateUp();
});
/*
"first_edit_depict" is a key for getting information about opening the depiction editor
screen for the first time after opening the app.
Getting true by the key means the depiction editor screen is opened for the first time
after opening the app.
Getting false by the key means the depiction editor screen is not opened for the first time
after opening the app.
*/
applicationKvStore.putBoolean("first_edit_depict", true);
if (applicationKvStore.getBoolean("login_skipped") == true) {
setTitle(getString(R.string.navigation_item_explore));
setUpLoggedOutPager();
} else {
if (applicationKvStore.getBoolean("firstrun", true)) {
applicationKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", false);
applicationKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", false);
}
if (savedInstanceState == null) {
//starting a fresh fragment.
// Open Last opened screen if it is Contributions or Nearby, otherwise Contributions
if (applicationKvStore.getBoolean("last_opened_nearby")) {
setTitle(getString(R.string.nearby_fragment));
showNearby();
loadFragment(NearbyParentFragment.newInstance(), false);
} else {
setTitle(getString(R.string.contributions_fragment));
loadFragment(ContributionsFragment.newInstance(), false);
}
}
setUpPager();
/**
* Ask the user for media location access just after login
* so that location in the EXIF metadata of the images shared by the user
* is retained on devices running Android 10 or above
*/
// if (VERSION.SDK_INT >= VERSION_CODES.Q) {
// ActivityCompat.requestPermissions(this,
// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0);
// PermissionUtils.checkPermissionsAndPerformAction(
// this,
// () -> {},
// R.string.media_location_permission_denied,
// R.string.add_location_manually,
// permission.ACCESS_MEDIA_LOCATION);
// }
checkAndResumeStuckUploads();
}
}
public void setSelectedItemId(int id) {
binding.fragmentMainNavTabLayout.setSelectedItemId(id);
}
private void setUpPager() {
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(
navListener = (item) -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
}
// set last_opened_nearby true if item is nearby screen else set false
applicationKvStore.putBoolean("last_opened_nearby",
item.getTitle().equals(getString(R.string.nearby_fragment)));
final Fragment fragment = NavTab.of(item.getOrder()).newInstance();
return loadFragment(fragment, true);
});
}
private void setUpLoggedOutPager() {
loadFragment(ExploreFragment.newInstance(), false);
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(item -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
}
Fragment fragment = NavTabLoggedOut.of(item.getOrder()).newInstance();
return loadFragment(fragment, true);
});
}
private boolean loadFragment(Fragment fragment, boolean showBottom) {
//showBottom so that we do not show the bottom tray again when constructing
//from the saved instance state.
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;
default:
return super.onOptionsItemSelected(item);
}
}
public void centerMapToPlace(Place place) {
setSelectedItemId(NavTab.NEARBY.code());
nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback(
new NearbyParentFragmentInstanceReadyCallback() {
@Override
public void onReady() {
nearbyParentFragment.centerMapToPlace(place);
}
});
}
@Override
protected void onResume() {
super.onResume();
if ((applicationKvStore.getBoolean("firstrun", true)) &&
(!applicationKvStore.getBoolean("login_skipped"))) {
defaultKvStore.putBoolean("inAppCameraFirstRun", true);
WelcomeActivity.startYourself(this);
}
retryAllFailedUploads();
}
@Override
protected void onDestroy() {
quizChecker.cleanup();
locationManager.unregisterLocationManager();
// Remove ourself from hashmap to prevent memory leaks
locationManager = null;
super.onDestroy();
}
/**
* Public method to show nearby from the reference of this.
*/
public void showNearby() {
binding.fragmentMainNavTabLayout.setSelectedItemId(NavTab.NEARBY.code());
}
public enum ActiveFragment {
CONTRIBUTIONS,
NEARBY,
EXPLORE,
BOOKMARK,
MORE
}
/**
* Load default language in onCreate from SharedPreferences
*/
private void loadLocale() {
final SharedPreferences preferences = getSharedPreferences("Settings",
Activity.MODE_PRIVATE);
final String language = preferences.getString("language", "");
final SettingsFragment settingsFragment = new SettingsFragment();
settingsFragment.setLocale(this, language);
}
public NavTabLayout.OnNavigationItemSelectedListener getNavListener() {
return navListener;
}
}

View file

@ -0,0 +1,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

@ -18,8 +18,9 @@ class DBOpenHelper(
companion object {
private const val DATABASE_NAME = "commons.db"
private const val DATABASE_VERSION = 21
private const val DATABASE_VERSION = 22
const val CONTRIBUTIONS_TABLE = "contributions"
const val BOOKMARKS_LOCATIONS = "bookmarksLocations"
private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS %s"
}
@ -30,7 +31,6 @@ class DBOpenHelper(
override fun onCreate(db: SQLiteDatabase) {
CategoryDao.Table.onCreate(db)
BookmarkPicturesDao.Table.onCreate(db)
BookmarkLocationsDao.Table.onCreate(db)
BookmarkItemsDao.Table.onCreate(db)
RecentSearchesDao.Table.onCreate(db)
RecentLanguagesDao.Table.onCreate(db)
@ -39,11 +39,11 @@ class DBOpenHelper(
override fun onUpgrade(db: SQLiteDatabase, from: Int, to: Int) {
CategoryDao.Table.onUpdate(db, from, to)
BookmarkPicturesDao.Table.onUpdate(db, from, to)
BookmarkLocationsDao.Table.onUpdate(db, from, to)
BookmarkItemsDao.Table.onUpdate(db, from, to)
RecentSearchesDao.Table.onUpdate(db, from, to)
RecentLanguagesDao.Table.onUpdate(db, from, to)
deleteTable(db, CONTRIBUTIONS_TABLE)
deleteTable(db, BOOKMARKS_LOCATIONS)
}
/**

View file

@ -1,10 +1,16 @@
package fr.free.nrw.commons.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.bookmarks.locations.BookmarksLocations
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
@ -23,8 +29,8 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao
*
*/
@Database(
entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class, BookmarksCategoryModal::class],
version = 19,
entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class, BookmarksCategoryModal::class, BookmarksLocations::class],
version = 20,
exportSchema = false,
)
@TypeConverters(Converters::class)
@ -42,4 +48,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun ReviewDao(): ReviewDao
abstract fun bookmarkCategoriesDao(): BookmarkCategoriesDao
abstract fun bookmarkLocationsDao(): BookmarkLocationsDao
}

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

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.view.inputmethod.InputMethodManager
import androidx.collection.LruCache
import androidx.room.Room.databaseBuilder
@ -16,6 +17,7 @@ import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
@ -36,6 +38,7 @@ import fr.free.nrw.commons.wikidata.WikidataEditListenerImpl
import io.reactivex.Scheduler
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.util.Objects
import javax.inject.Named
import javax.inject.Singleton
@ -49,6 +52,11 @@ import javax.inject.Singleton
@Module
@Suppress("unused")
open class CommonsApplicationModule(private val applicationContext: Context) {
init {
appContext = applicationContext
}
@Provides
fun providesImageFileLoader(context: Context): ImageFileLoader =
ImageFileLoader(context)
@ -110,11 +118,6 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
fun provideBookmarkContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.BOOKMARK_AUTHORITY)
@Provides
@Named("bookmarksLocation")
fun provideBookmarkLocationContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY)
@Provides
@Named("bookmarksItem")
fun provideBookmarkItemContentProviderClient(context: Context): ContentProviderClient? =
@ -196,7 +199,10 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
applicationContext,
AppDatabase::class.java,
"commons_room.db"
).addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration().build()
).addMigrations(
MIGRATION_1_2,
MIGRATION_19_TO_20
).fallbackToDestructiveMigration().build()
@Provides
fun providesContributionsDao(appDatabase: AppDatabase): ContributionDao =
@ -206,6 +212,10 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
fun providesPlaceDao(appDatabase: AppDatabase): PlaceDao =
appDatabase.PlaceDao()
@Provides
fun providesBookmarkLocationsDao(appDatabase: AppDatabase): BookmarkLocationsDao =
appDatabase.bookmarkLocationsDao()
@Provides
fun providesDepictDao(appDatabase: AppDatabase): DepictsDao =
appDatabase.DepictsDao()
@ -239,6 +249,9 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
const val IO_THREAD: String = "io_thread"
const val MAIN_THREAD: String = "main_thread"
lateinit var appContext: Context
private set
val MIGRATION_1_2: Migration = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
@ -246,5 +259,101 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
)
}
}
private val MIGRATION_19_TO_20 = object : Migration(19, 20) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS bookmarks_locations (
location_name TEXT NOT NULL PRIMARY KEY,
location_language TEXT NOT NULL,
location_description TEXT NOT NULL,
location_lat REAL NOT NULL,
location_long REAL NOT NULL,
location_category TEXT NOT NULL,
location_label_text TEXT NOT NULL,
location_label_icon INTEGER,
location_image_url TEXT NOT NULL DEFAULT '',
location_wikipedia_link TEXT NOT NULL,
location_wikidata_link TEXT NOT NULL,
location_commons_link TEXT NOT NULL,
location_pic TEXT NOT NULL,
location_exists INTEGER NOT NULL CHECK(location_exists IN (0, 1))
)
"""
)
val oldDbPath = appContext.getDatabasePath("commons.db").path
val oldDb = SQLiteDatabase
.openDatabase(oldDbPath, null, SQLiteDatabase.OPEN_READONLY)
val cursor = oldDb.rawQuery("SELECT * FROM bookmarksLocations", null)
while (cursor.moveToNext()) {
val locationName =
cursor.getString(cursor.getColumnIndexOrThrow("location_name"))
val locationLanguage =
cursor.getString(cursor.getColumnIndexOrThrow("location_language"))
val locationDescription =
cursor.getString(cursor.getColumnIndexOrThrow("location_description"))
val locationCategory =
cursor.getString(cursor.getColumnIndexOrThrow("location_category"))
val locationLabelText =
cursor.getString(cursor.getColumnIndexOrThrow("location_label_text"))
val locationLabelIcon =
cursor.getInt(cursor.getColumnIndexOrThrow("location_label_icon"))
val locationLat =
cursor.getDouble(cursor.getColumnIndexOrThrow("location_lat"))
val locationLong =
cursor.getDouble(cursor.getColumnIndexOrThrow("location_long"))
// Handle NULL values safely
val locationImageUrl =
cursor.getString(
cursor.getColumnIndexOrThrow("location_image_url")
) ?: ""
val locationWikipediaLink =
cursor.getString(
cursor.getColumnIndexOrThrow("location_wikipedia_link")
) ?: ""
val locationWikidataLink =
cursor.getString(
cursor.getColumnIndexOrThrow("location_wikidata_link")
) ?: ""
val locationCommonsLink =
cursor.getString(
cursor.getColumnIndexOrThrow("location_commons_link")
) ?: ""
val locationPic =
cursor.getString(
cursor.getColumnIndexOrThrow("location_pic")
) ?: ""
val locationExists =
cursor.getInt(
cursor.getColumnIndexOrThrow("location_exists")
)
db.execSQL(
"""
INSERT OR REPLACE INTO bookmarks_locations (
location_name, location_language, location_description, location_category,
location_label_text, location_label_icon, location_lat, location_long,
location_image_url, location_wikipedia_link, location_wikidata_link,
location_commons_link, location_pic, location_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
arrayOf(
locationName, locationLanguage, locationDescription, locationCategory,
locationLabelText, locationLabelIcon, locationLat, locationLong,
locationImageUrl, locationWikipediaLink, locationWikidataLink,
locationCommonsLink, locationPic, locationExists
)
)
}
cursor.close()
oldDb.close()
}
}
}
}

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

@ -3,7 +3,6 @@ package fr.free.nrw.commons.di
import dagger.Module
import dagger.android.ContributesAndroidInjector
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsContentProvider
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsContentProvider
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider
import fr.free.nrw.commons.category.CategoryContentProvider
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesContentProvider
@ -26,9 +25,6 @@ abstract class ContentProviderBuilderModule {
@ContributesAndroidInjector
abstract fun bindBookmarkContentProvider(): BookmarkPicturesContentProvider
@ContributesAndroidInjector
abstract fun bindBookmarkLocationContentProvider(): BookmarkLocationsContentProvider
@ContributesAndroidInjector
abstract fun bindBookmarkItemContentProvider(): BookmarkItemsContentProvider

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) {
inflater.inflate(R.menu.menu_search, menu);
// if logged in 'Show in Nearby' menu item is visible
if (applicationKvStore.getBoolean("login_skipped") == false) {
inflater.inflate(R.menu.explore_fragment_menu, menu);
MenuItem others = menu.findItem(R.id.list_item_show_in_nearby);
if (binding.viewPager.getCurrentItem() == 2) {
others.setVisible(true);
}
// if on Map tab, show all menu options, else only show search
binding.viewPager.addOnPageChangeListener(new OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset,
int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
others.setVisible((position == 2));
}
@Override
public void onPageScrollStateChanged(int state) {
if (state == SCROLL_STATE_IDLE && binding.viewPager.getCurrentItem() == 2) {
onPageSelected(2);
}
}
});
} else {
inflater.inflate(R.menu.menu_search, menu);
}
super.onCreateOptionsMenu(menu, inflater);
}
@ -171,6 +243,9 @@ public class ExploreFragment extends CommonsDaggerSupportFragment {
case R.id.action_search:
ActivityUtils.startActivityWithFlags(getActivity(), SearchActivity.class);
return true;
case R.id.list_item_show_in_nearby:
mapRootFragment.loadNearbyMapFromExplore();
return true;
default:
return super.onOptionsItemSelected(item);
}

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);
binding.mapView.getController().setZoom(ZOOM_LEVEL);
if (!isCameFromNearbyMap()) {
binding.mapView.getController().setZoom(ZOOM_LEVEL);
}
performMapReadyActions();
binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() {
@ -295,7 +309,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
unregisterNetworkReceiver();
}
/**
* Unregisters the networkReceiver
*/
@ -328,11 +342,51 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
isPermissionDenied = true;
}
lastKnownLocation = MapUtils.getDefaultLatLng();
moveCameraToPosition(
new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude()));
// if we came from 'Show in Explore' in Nearby, load Nearby map center and zoom
if (isCameFromNearbyMap()) {
moveCameraToPosition(
new GeoPoint(prevLatitude, prevLongitude),
prevZoom,
1L
);
} else {
moveCameraToPosition(
new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude()));
}
presenter.onMapReady(exploreMapController);
}
/**
* Fetch Nearby map camera data from fragment arguments if any.
*/
public void loadNearbyMapData() {
// get fragment arguments
if (getArguments() != null) {
prevZoom = getArguments().getDouble("prev_zoom");
prevLatitude = getArguments().getDouble("prev_latitude");
prevLongitude = getArguments().getDouble("prev_longitude");
}
}
/**
* Checks if fragment arguments contain data from Nearby map, indicating that the user navigated
* from Nearby using 'Show in Explore'.
*
* @return true if user navigated from Nearby map
**/
public boolean isCameFromNearbyMap() {
return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0;
}
public void loadNearbyMapFromExplore() {
((MainActivity) getContext()).loadNearbyMapFromExplore(
binding.mapView.getZoomLevelDouble(),
binding.mapView.getMapCenter().getLatitude(),
binding.mapView.getMapCenter().getLongitude()
);
}
private void initViews() {
Timber.d("init views called");
initBottomSheets();
@ -346,7 +400,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
*/
@SuppressLint("ClickableViewAccessibility")
private void initBottomSheets() {
bottomSheetDetailsBehavior = BottomSheetBehavior.from(binding.bottomSheetDetailsBinding.getRoot());
bottomSheetDetailsBehavior = BottomSheetBehavior.from(
binding.bottomSheetDetailsBinding.getRoot());
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
binding.bottomSheetDetailsBinding.getRoot().setVisibility(View.VISIBLE);
}
@ -404,23 +459,25 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
if (currentLatLng == null) {
return;
}
if (currentLatLng.equals(getLastMapFocus())) { // Means we are checking around current location
if (currentLatLng.equals(
getLastMapFocus())) { // Means we are checking around current location
nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(currentLatLng,
getLastMapFocus(), true);
} else {
nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(getLastMapFocus(),
currentLatLng, false);
}
compositeDisposable.add(nearbyPlacesInfoObservable
getCompositeDisposable().add(nearbyPlacesInfoObservable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(explorePlacesInfo -> {
mediaList = explorePlacesInfo.mediaList;
if(mediaList == null) {
if (mediaList == null) {
showResponseMessage(getString(R.string.no_pictures_in_this_area));
}
updateMapMarkers(explorePlacesInfo);
lastMapFocus = new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude());
lastMapFocus = new GeoPoint(currentLatLng.getLatitude(),
currentLatLng.getLongitude());
},
throwable -> {
Timber.d(throwable);
@ -474,9 +531,9 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER);
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
setProgressBarVisibility(true);
}
else {
locationPermissionsHelper.showLocationOffDialog(getActivity(), R.string.ask_to_turn_location_on_text);
} else {
locationPermissionsHelper.showLocationOffDialog(getActivity(),
R.string.ask_to_turn_location_on_text);
}
presenter.onMapReady(exploreMapController);
registerUnregisterLocationListener(false);
@ -508,7 +565,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
recenterToUserLocation = true;
return;
}
recenterMarkerToPosition(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude()));
recenterMarkerToPosition(
new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude()));
binding.mapView.getController()
.animateTo(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude()));
if (lastMapFocus != null) {
@ -545,10 +603,12 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
* @param place Place of clicked nearby marker
*/
private void passInfoToSheet(final Place place) {
binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener(view -> Utils.handleGeoCoordinates(getActivity(),
place.getLocation()));
binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener(
view -> Utils.handleGeoCoordinates(getActivity(),
place.getLocation(), binding.mapView.getZoomLevelDouble()));
binding.bottomSheetDetailsBinding.commonsButton.setVisibility(place.hasCommonsLink() ? View.VISIBLE : View.GONE);
binding.bottomSheetDetailsBinding.commonsButton.setVisibility(
place.hasCommonsLink() ? View.VISIBLE : View.GONE);
binding.bottomSheetDetailsBinding.commonsButton.setOnClickListener(
view -> Utils.handleWebUrl(getContext(), place.siteLinks.getCommonsLink()));
@ -562,7 +622,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
}
index++;
}
binding.bottomSheetDetailsBinding.title.setText(place.name.substring(5, place.name.lastIndexOf(".")));
binding.bottomSheetDetailsBinding.title.setText(
place.name.substring(5, place.name.lastIndexOf(".")));
binding.bottomSheetDetailsBinding.category.setText(place.distance);
// Remove label since it is double information
String descriptionText = place.getLongDescription()
@ -640,40 +701,43 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
* @param nearbyBaseMarker The NearbyBaseMarker object representing the marker to be added.
*/
private void addMarkerToMap(BaseMarker nearbyBaseMarker) {
ArrayList<OverlayItem> items = new ArrayList<>();
Bitmap icon = nearbyBaseMarker.getIcon();
Drawable d = new BitmapDrawable(getResources(), icon);
GeoPoint point = new GeoPoint(
nearbyBaseMarker.getPlace().location.getLatitude(),
nearbyBaseMarker.getPlace().location.getLongitude());
OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null,
point);
item.setMarker(d);
items.add(item);
ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items,
new OnItemGestureListener<OverlayItem>() {
@Override
public boolean onItemSingleTapUp(int index, OverlayItem item) {
final Place place = nearbyBaseMarker.getPlace();
if (clickedMarker != null) {
removeMarker(clickedMarker);
addMarkerToMap(clickedMarker);
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
if (isAttachedToActivity()) {
ArrayList<OverlayItem> items = new ArrayList<>();
Bitmap icon = nearbyBaseMarker.getIcon();
Drawable d = new BitmapDrawable(getResources(), icon);
GeoPoint point = new GeoPoint(
nearbyBaseMarker.getPlace().location.getLatitude(),
nearbyBaseMarker.getPlace().location.getLongitude());
OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null,
point);
item.setMarker(d);
items.add(item);
ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items,
new OnItemGestureListener<OverlayItem>() {
@Override
public boolean onItemSingleTapUp(int index, OverlayItem item) {
final Place place = nearbyBaseMarker.getPlace();
if (clickedMarker != null) {
removeMarker(clickedMarker);
addMarkerToMap(clickedMarker);
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
bottomSheetDetailsBehavior.setState(
BottomSheetBehavior.STATE_COLLAPSED);
}
clickedMarker = nearbyBaseMarker;
passInfoToSheet(place);
return true;
}
clickedMarker = nearbyBaseMarker;
passInfoToSheet(place);
return true;
}
@Override
public boolean onItemLongPress(int index, OverlayItem item) {
return false;
}
}, getContext());
@Override
public boolean onItemLongPress(int index, OverlayItem item) {
return false;
}
}, getContext());
overlay.setFocusItemsOnTap(true);
binding.mapView.getOverlays().add(overlay); // Add the overlay to the map
overlay.setFocusItemsOnTap(true);
binding.mapView.getOverlays().add(overlay); // Add the overlay to the map
}
}
/**
@ -707,68 +771,72 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
*/
@Override
public void clearAllMarkers() {
binding.mapView.getOverlayManager().clear();
GeoPoint geoPoint = mapCenter;
if (geoPoint != null) {
List<Overlay> overlays = binding.mapView.getOverlays();
ScaleDiskOverlay diskOverlay =
new ScaleDiskOverlay(this.getContext(),
geoPoint, 2000, GeoConstants.UnitOfMeasure.foot);
Paint circlePaint = new Paint();
circlePaint.setColor(Color.rgb(128, 128, 128));
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(2f);
diskOverlay.setCirclePaint2(circlePaint);
Paint diskPaint = new Paint();
diskPaint.setColor(Color.argb(40, 128, 128, 128));
diskPaint.setStyle(Paint.Style.FILL_AND_STROKE);
diskOverlay.setCirclePaint1(diskPaint);
diskOverlay.setDisplaySizeMin(900);
diskOverlay.setDisplaySizeMax(1700);
binding.mapView.getOverlays().add(diskOverlay);
org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker(
binding.mapView);
startMarker.setPosition(geoPoint);
startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER,
org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM);
startMarker.setIcon(
ContextCompat.getDrawable(this.getContext(), R.drawable.current_location_marker));
startMarker.setTitle("Your Location");
startMarker.setTextLabelFontSize(24);
binding.mapView.getOverlays().add(startMarker);
}
ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView);
scaleBarOverlay.setScaleBarOffset(15, 25);
Paint barPaint = new Paint();
barPaint.setARGB(200, 255, 250, 250);
scaleBarOverlay.setBackgroundPaint(barPaint);
scaleBarOverlay.enableScaleBar();
binding.mapView.getOverlays().add(scaleBarOverlay);
binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() {
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
if (clickedMarker != null) {
removeMarker(clickedMarker);
addMarkerToMap(clickedMarker);
binding.mapView.invalidate();
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (bottomSheetDetailsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
// Back should first hide the bottom sheet if it is expanded
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else if (isDetailsBottomSheetVisible()) {
hideBottomDetailsSheet();
}
return true;
if (isAttachedToActivity()) {
binding.mapView.getOverlayManager().clear();
GeoPoint geoPoint = mapCenter;
if (geoPoint != null) {
List<Overlay> overlays = binding.mapView.getOverlays();
ScaleDiskOverlay diskOverlay =
new ScaleDiskOverlay(this.getContext(),
geoPoint, 2000, GeoConstants.UnitOfMeasure.foot);
Paint circlePaint = new Paint();
circlePaint.setColor(Color.rgb(128, 128, 128));
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(2f);
diskOverlay.setCirclePaint2(circlePaint);
Paint diskPaint = new Paint();
diskPaint.setColor(Color.argb(40, 128, 128, 128));
diskPaint.setStyle(Paint.Style.FILL_AND_STROKE);
diskOverlay.setCirclePaint1(diskPaint);
diskOverlay.setDisplaySizeMin(900);
diskOverlay.setDisplaySizeMax(1700);
binding.mapView.getOverlays().add(diskOverlay);
org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker(
binding.mapView);
startMarker.setPosition(geoPoint);
startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER,
org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM);
startMarker.setIcon(
ContextCompat.getDrawable(this.getContext(),
R.drawable.current_location_marker));
startMarker.setTitle("Your Location");
startMarker.setTextLabelFontSize(24);
binding.mapView.getOverlays().add(startMarker);
}
ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView);
scaleBarOverlay.setScaleBarOffset(15, 25);
Paint barPaint = new Paint();
barPaint.setARGB(200, 255, 250, 250);
scaleBarOverlay.setBackgroundPaint(barPaint);
scaleBarOverlay.enableScaleBar();
binding.mapView.getOverlays().add(scaleBarOverlay);
binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() {
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
if (clickedMarker != null) {
removeMarker(clickedMarker);
addMarkerToMap(clickedMarker);
binding.mapView.invalidate();
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (bottomSheetDetailsBehavior.getState()
== BottomSheetBehavior.STATE_EXPANDED) {
// Back should first hide the bottom sheet if it is expanded
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
} else if (isDetailsBottomSheetVisible()) {
hideBottomDetailsSheet();
}
return true;
}
@Override
public boolean longPressHelper(GeoPoint p) {
return false;
}
}));
binding.mapView.setMultiTouchControls(true);
@Override
public boolean longPressHelper(GeoPoint p) {
return false;
}
}));
binding.mapView.setMultiTouchControls(true);
}
}
/**
@ -825,6 +893,18 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
binding.mapView.getController().animateTo(geoPoint);
}
/**
* Moves the camera of the map view to the specified GeoPoint at specified zoom level and speed
* using an animation.
*
* @param geoPoint The GeoPoint representing the new camera position for the map.
* @param zoom Zoom level of the map camera
* @param speed Speed of animation
*/
private void moveCameraToPosition(GeoPoint geoPoint, double zoom, long speed) {
binding.mapView.getController().animateTo(geoPoint, zoom, speed);
}
@Override
public fr.free.nrw.commons.location.LatLng getLastMapFocus() {
return lastMapFocus == null ? getMapCenter() : new fr.free.nrw.commons.location.LatLng(
@ -850,14 +930,17 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
-0.07483536015053005, 1f);
}
}
moveCameraToPosition(new GeoPoint(latLnge.getLatitude(),latLnge.getLongitude()));
if (!isCameFromNearbyMap()) {
moveCameraToPosition(new GeoPoint(latLnge.getLatitude(), latLnge.getLongitude()));
}
return latLnge;
}
@Override
public fr.free.nrw.commons.location.LatLng getMapFocus() {
fr.free.nrw.commons.location.LatLng mapFocusedLatLng = new fr.free.nrw.commons.location.LatLng(
binding.mapView.getMapCenter().getLatitude(), binding.mapView.getMapCenter().getLongitude(), 100);
binding.mapView.getMapCenter().getLatitude(),
binding.mapView.getMapCenter().getLongitude(), 100);
return mapFocusedLatLng;
}
@ -910,9 +993,19 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
};
}
@Override
public void onLocationPermissionDenied(String toastMessage) {}
/**
* helper function to confirm that this fragment has been attached.
**/
public boolean isAttachedToActivity() {
boolean attached = isVisible() && getActivity() != null;
return attached;
}
@Override
public void onLocationPermissionGranted() {}
public void onLocationPermissionDenied(String toastMessage) {
}
@Override
public void onLocationPermissionGranted() {
}
}

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

@ -16,6 +16,7 @@ import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.ArrayAdapter
import android.widget.Button
@ -405,9 +406,14 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
* Gets the height of the frame layout as soon as the view is ready and updates aspect ratio
* of the picture.
*/
view.post {
frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight
updateAspectRatio(binding.mediaDetailScrollView.width)
view.post{
val width = binding.mediaDetailScrollView.width
if (width > 0) {
frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight
updateAspectRatio(width)
} else {
view.postDelayed({ updateAspectRatio(binding.root.width) }, 1)
}
}
return view
@ -493,7 +499,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 +656,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 +867,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 +893,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

@ -185,10 +185,12 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
* or a fragment
*/
private void initProvider() {
if (getParentFragment() != null) {
if (getParentFragment() instanceof MediaDetailProvider) {
provider = (MediaDetailProvider) getParentFragment();
} else {
} else if (getActivity() instanceof MediaDetailProvider) {
provider = (MediaDetailProvider) getActivity();
} else {
throw new ClassCastException("Parent must implement MediaDetailProvider");
}
}

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

@ -68,7 +68,21 @@ class BottomSheetAdapter(
item.imageResourceId == R.drawable.ic_round_star_border_24px
) {
item.imageResourceId = icon
this.notifyItemChanged(index)
notifyItemChanged(index)
return
}
}
}
fun toggleBookmarkIcon() {
itemList.forEachIndexed { index, item ->
if(item.imageResourceId == R.drawable.ic_round_star_filled_24px) {
item.imageResourceId = R.drawable.ic_round_star_border_24px
notifyItemChanged(index)
return
} else if(item.imageResourceId == R.drawable.ic_round_star_border_24px){
item.imageResourceId = R.drawable.ic_round_star_filled_24px
notifyItemChanged(index)
return
}
}

View file

@ -0,0 +1,28 @@
package fr.free.nrw.commons.nearby
import android.util.Log
import androidx.lifecycle.LifecycleCoroutineScope
import fr.free.nrw.commons.R
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import kotlinx.coroutines.launch
import timber.log.Timber
object NearbyUtil {
fun getBookmarkLocationExists(
bookmarksLocationsDao: BookmarkLocationsDao,
name: String,
scope: LifecycleCoroutineScope?,
bottomSheetAdapter: BottomSheetAdapter,
) {
scope?.launch {
val isBookmarked = bookmarksLocationsDao.findBookmarkLocation(name)
Timber.i("isBookmarked: $isBookmarked")
if (isBookmarked) {
bottomSheetAdapter.updateBookmarkIcon(R.drawable.ic_round_star_filled_24px)
} else {
bottomSheetAdapter.updateBookmarkIcon(R.drawable.ic_round_star_border_24px)
}
}
}
}

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

View file

@ -7,6 +7,7 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.widget.RelativeLayout
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
@ -16,9 +17,11 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import fr.free.nrw.commons.R
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.databinding.ItemPlaceBinding
import kotlinx.coroutines.launch
fun placeAdapterDelegate(
bookmarkLocationDao: BookmarkLocationsDao,
scope: LifecycleCoroutineScope?,
onItemClick: ((Place) -> Unit)? = null,
onCameraClicked: (Place, ActivityResultLauncher<Array<String>>, ActivityResultLauncher<Intent>) -> Unit,
onCameraLongPressed: () -> Boolean,
@ -61,7 +64,10 @@ fun placeAdapterDelegate(
nearbyButtonLayout.galleryButton.setOnClickListener { onGalleryClicked(item, galleryPickLauncherForResult) }
nearbyButtonLayout.galleryButton.setOnLongClickListener { onGalleryLongPressed() }
bookmarkButtonImage.setOnClickListener {
val isBookmarked = bookmarkLocationDao.updateBookmarkLocation(item)
var isBookmarked = false
scope?.launch {
isBookmarked = bookmarkLocationDao.updateBookmarkLocation(item)
}
bookmarkButtonImage.setImageResource(
if (isBookmarked) R.drawable.ic_round_star_filled_24px else R.drawable.ic_round_star_border_24px,
)
@ -93,13 +99,15 @@ fun placeAdapterDelegate(
GONE
}
bookmarkButtonImage.setImageResource(
if (bookmarkLocationDao.findBookmarkLocation(item)) {
R.drawable.ic_round_star_filled_24px
} else {
R.drawable.ic_round_star_border_24px
},
)
scope?.launch {
bookmarkButtonImage.setImageResource(
if (bookmarkLocationDao.findBookmarkLocation(item.name)) {
R.drawable.ic_round_star_filled_24px
} else {
R.drawable.ic_round_star_border_24px
},
)
}
}
nearbyButtonLayout.directionsButton.setOnLongClickListener { onDirectionsLongPressed() }
}

View file

@ -134,7 +134,7 @@ public interface NearbyParentFragmentContract {
void setAdvancedQuery(String query);
void toggleBookmarkedStatus(Place place);
void toggleBookmarkedStatus(Place place, LifecycleCoroutineScope scope);
void handleMapScrolled(LifecycleCoroutineScope scope, boolean isNetworkAvailable);
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.nearby.fragments
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.LifecycleCoroutineScope
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.placeAdapterDelegate
@ -9,6 +10,7 @@ import fr.free.nrw.commons.upload.categories.BaseDelegateAdapter
class PlaceAdapter(
bookmarkLocationsDao: BookmarkLocationsDao,
scope: LifecycleCoroutineScope? = null,
onPlaceClicked: ((Place) -> Unit)? = null,
onBookmarkClicked: (Place, Boolean) -> Unit,
commonPlaceClickActions: CommonPlaceClickActions,
@ -18,6 +20,7 @@ class PlaceAdapter(
) : BaseDelegateAdapter<Place>(
placeAdapterDelegate(
bookmarkLocationsDao,
scope,
onPlaceClicked,
commonPlaceClickActions.onCameraClicked(),
commonPlaceClickActions.onCameraLongPressed(),

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,13 @@ 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.runBlocking
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 +79,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 +95,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
@ -133,25 +137,31 @@ class NearbyParentFragmentPresenter
* @param place The place whose bookmarked status is to be toggled. If the place is `null`,
* the operation is skipped.
*/
override fun toggleBookmarkedStatus(place: Place?) {
override fun toggleBookmarkedStatus(
place: Place?,
scope: LifecycleCoroutineScope?
) {
if (place == null) return
val nowBookmarked = bookmarkLocationDao.updateBookmarkLocation(place)
bookmarkChangedPlaces.add(place)
val placeIndex =
NearbyController.markerLabelList.indexOfFirst { it.place.location == place.location }
NearbyController.markerLabelList[placeIndex] = MarkerPlaceGroup(
nowBookmarked,
NearbyController.markerLabelList[placeIndex].place
)
nearbyParentFragmentView.setFilterState()
var nowBookmarked: Boolean
scope?.launch {
nowBookmarked = bookmarkLocationDao.updateBookmarkLocation(place)
bookmarkChangedPlaces.add(place)
val placeIndex =
NearbyController.markerLabelList.indexOfFirst { it.place.location == place.location }
NearbyController.markerLabelList[placeIndex] = MarkerPlaceGroup(
nowBookmarked,
NearbyController.markerLabelList[placeIndex].place
)
nearbyParentFragmentView.setFilterState()
}
}
override fun attachView(view: NearbyParentFragmentContract.View) {
this.nearbyParentFragmentView = view
nearbyParentFragmentView = view
}
override fun detachView() {
this.nearbyParentFragmentView = DUMMY
nearbyParentFragmentView = DUMMY
}
override fun removeNearbyPreferences(applicationKvStore: JsonKvStore) {
@ -334,7 +344,7 @@ class NearbyParentFragmentPresenter
for (i in 0..updatedGroups.lastIndex) {
val repoPlace = placesRepository.fetchPlace(updatedGroups[i].place.entityID)
if (repoPlace != null && repoPlace.name != null && repoPlace.name != ""){
updatedGroups[i].isBookmarked = bookmarkLocationDao.findBookmarkLocation(repoPlace)
updatedGroups[i].isBookmarked = bookmarkLocationDao.findBookmarkLocation(repoPlace.name)
updatedGroups[i].place.apply {
name = repoPlace.name
isMonument = repoPlace.isMonument
@ -372,20 +382,42 @@ class NearbyParentFragmentPresenter
collectResults.send(
fetchedPlaces.mapIndexed { index, place ->
Pair(indices[index], MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(place),
bookmarkLocationDao.findBookmarkLocation(place.name),
place
))
}
)
} 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].name
),
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(
@ -435,16 +467,14 @@ class NearbyParentFragmentPresenter
if (bookmarkChangedPlacesBacklog.containsKey(group.place.location)) {
updatedGroups[index] = MarkerPlaceGroup(
bookmarkLocationDao
.findBookmarkLocation(updatedGroups[index].place),
.findBookmarkLocation(updatedGroups[index].place.name),
updatedGroups[index].place
)
}
}
}
schedulePlacesUpdate(updatedGroups)
if (++collectCount == totalBatches) {
break
}
collectCount += resultList.size
}
collectResults.close()
}
@ -545,7 +575,7 @@ class NearbyParentFragmentPresenter
).sortedBy { it.getDistanceInDouble(mapFocus) }.take(NearbyController.MAX_RESULTS)
.map {
MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(it), it
bookmarkLocationDao.findBookmarkLocation(it.name), it
)
}
ensureActive()

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

@ -188,7 +188,7 @@ class ReviewActivity : BaseActivity() {
return
}
binding.reviewImageView.setImageURI(media.imageUrl)
binding.reviewImageView.setImageURI(media.thumbUrl)
reviewController.onImageRefreshed(media) // filename is updated
compositeDisposable.add(

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,12 +88,16 @@ class PendingUploadsPresenter @Inject internal constructor(
* @param context The context in which the operation is being performed.
*/
override fun deleteUpload(contribution: Contribution?, context: Context?) {
compositeDisposable.add(
contribution?.let {
contributionsRepository
.deleteContributionFromDB(contribution)
.deleteContributionFromDB(it)
.subscribeOn(ioThreadScheduler)
.subscribe()
)
}?.let {
compositeDisposable.add(
it
)
}
}
/**
@ -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

@ -701,7 +701,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,10 @@ class UploadMediaPresenter @Inject constructor(
*/
override fun checkImageQuality(uploadItem: UploadItem, index: Int) {
if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) {
val value = basicKvStoreFactory(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE)
.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 +365,9 @@ class UploadMediaPresenter @Inject constructor(
* @param index Index of the UploadItem which was deleted
*/
override fun updateImageQualitiesJSON(size: Int, index: Int) {
val value = basicKvStoreFactory(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE)
.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 +375,9 @@ class UploadMediaPresenter @Inject constructor(
}
remove("UploadItem" + (size - 1))
}
basicKvStoreFactory(UploadActivity.STORE_NAME_FOR_CURRENT_UPLOAD_IMAGE_SIZE)
.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

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%"
android:duration="500"
android:repeatCount="infinite"
android:interpolator="@android:anim/linear_interpolator"/>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,4 A8,8 0 1,1 4,12 A8,8 0 1,5 19.42 ,15"
android:strokeWidth="2"
android:strokeColor="#2196F3"
android:fillColor="#00000000"
android:trimPathStart="0.2"
android:trimPathEnd="1.0"/>
</vector>
</item>
<item>
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,4 A8,8 0 1,1 4,12 A8,8 0 1,1 12,4"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:fillColor="#00000000"
android:trimPathStart="0.0"
android:trimPathEnd="0.2"/>
</vector>
</item>
<item android:right="12dp">
<rotate
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%">
<shape android:shape="ring">
<size
android:width="12dp"
android:height="2dp"/>
<solid android:color="#2196F3"/>
</shape>
</rotate>
</item>
</layer-list>

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>
@ -882,4 +884,7 @@
<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

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

Some files were not shown because too many files have changed in this diff Show more