diff --git a/.github/workflows/android-ci-comment.yml b/.github/workflows/android-ci-comment.yml new file mode 100644 index 000000000..b200c9a70 --- /dev/null +++ b/.github/workflows/android-ci-comment.yml @@ -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}`); + } diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7a1e7c030..8a744bc0a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml new file mode 100644 index 000000000..933d08e3e --- /dev/null +++ b/.github/workflows/build-beta.yml @@ -0,0 +1,41 @@ +name: Build beta only + +on: [workflow_dispatch] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Access test login credentials + run: | + echo "TEST_USER_NAME=${{ secrets.TEST_USER_NAME }}" >> local.properties + echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> local.properties + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Set env + run: echo "COMMIT_SHA=$(git log -n 1 --format='%h')" >> $GITHUB_ENV + + - name: Generate betaDebug APK + run: ./gradlew assembleBetaDebug --stacktrace + + - name: Rename betaDebug APK + run: mv app/build/outputs/apk/beta/debug/app-*.apk app/build/outputs/apk/beta/debug/apps-android-commons-betaDebug-$COMMIT_SHA.apk + + - name: Upload betaDebug APK + uses: actions/upload-artifact@v4 + with: + name: apps-android-commons-betaDebugAPK-${{ env.COMMIT_SHA }} + path: app/build/outputs/apk/beta/debug/*.apk + retention-days: 30 diff --git a/.gitignore b/.gitignore index e54ea2551..7fa4767a7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ captures/* # Test and other output app/jacoco.exec -app/CommonsContributions \ No newline at end of file +app/CommonsContributions +app/.* diff --git a/README.md b/README.md index cefb267aa..0b31ff5be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 2bde0d4f1..dbb5458bd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fb776920e..d56a874b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -232,12 +232,6 @@ android:exported="false" android:label="@string/provider_bookmarks" android:syncable="false" /> - sortedLocalizedNamesRef = CommonsApplication.getInstance().getLanguageLookUpTable().getCanonicalNames(); - Collections.sort(sortedLocalizedNamesRef); - final ArrayAdapter languageAdapter = new ArrayAdapter<>(AboutActivity.this, - android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef); - final Spinner spinner = new Spinner(AboutActivity.this); - spinner.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - spinner.setAdapter(languageAdapter); - spinner.setGravity(17); - spinner.setPadding(50,0,0,0); - - Runnable positiveButtonRunnable = () -> { - String langCode = CommonsApplication.getInstance().getLanguageLookUpTable().getCodes().get(spinner.getSelectedItemPosition()); - Utils.handleWebUrl(AboutActivity.this, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode)); - }; - DialogUtil.showAlertDialog(this, - getString(R.string.about_translate_title), - getString(R.string.about_translate_message), - getString(R.string.about_translate_proceed), - getString(R.string.about_translate_cancel), - positiveButtonRunnable, - () -> {}, - spinner - ); - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt new file mode 100644 index 000000000..143f5e569 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/AboutActivity.kt @@ -0,0 +1,209 @@ +package fr.free.nrw.commons + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.Spinner +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.databinding.ActivityAboutBinding +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import java.util.Collections + +/** + * Represents about screen of this app + */ +class AboutActivity : BaseActivity() { + /* + This View Binding class is auto-generated for each xml file. The format is usually the name + of the file with PascalCasing (The underscore characters will be ignored). + More information is available at https://developer.android.com/topic/libraries/view-binding + */ + private var binding: ActivityAboutBinding? = null + + /** + * This method helps in the creation About screen + * + * @param savedInstanceState Data bundle + */ + @SuppressLint("StringFormatInvalid") //TODO: + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + /* + Instead of just setting the view with the xml file. We need to use View Binding class. + */ + binding = ActivityAboutBinding.inflate(layoutInflater) + val view: View = binding!!.root + setContentView(view) + + setSupportActionBar(binding!!.toolbarBinding.toolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + val aboutText = getString(R.string.about_license) + /* + We can then access all the views by just using the id names like this. + camelCasing is used with underscore characters being ignored. + */ + binding!!.aboutLicense.setHtmlText(aboutText) + + @SuppressLint("StringFormatMatches") // TODO: + val improveText = + String.format(getString(R.string.about_improve), Urls.NEW_ISSUE_URL) + binding!!.aboutImprove.setHtmlText(improveText) + binding!!.aboutVersion.text = applicationContext.getVersionNameWithSha() + + Utils.setUnderlinedText( + binding!!.aboutFaq, R.string.about_faq, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutRateUs, R.string.about_rate_us, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutUserGuide, R.string.user_guide, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutPrivacyPolicy, R.string.about_privacy_policy, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutTranslate, R.string.about_translate, + applicationContext + ) + Utils.setUnderlinedText( + binding!!.aboutCredits, R.string.about_credits, + applicationContext + ) + + /* + To set listeners, we can create a separate method and use lambda syntax. + */ + binding!!.facebookLaunchIcon.setOnClickListener(::launchFacebook) + binding!!.githubLaunchIcon.setOnClickListener(::launchGithub) + binding!!.websiteLaunchIcon.setOnClickListener(::launchWebsite) + binding!!.aboutRateUs.setOnClickListener(::launchRatings) + binding!!.aboutCredits.setOnClickListener(::launchCredits) + binding!!.aboutPrivacyPolicy.setOnClickListener(::launchPrivacyPolicy) + binding!!.aboutUserGuide.setOnClickListener(::launchUserGuide) + binding!!.aboutFaq.setOnClickListener(::launchFrequentlyAskedQuesions) + binding!!.aboutTranslate.setOnClickListener(::launchTranslate) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } + + fun launchFacebook(view: View?) { + val intent: Intent + try { + intent = Intent(Intent.ACTION_VIEW, Uri.parse(Urls.FACEBOOK_APP_URL)) + intent.setPackage(Urls.FACEBOOK_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + Utils.handleWebUrl(this, Uri.parse(Urls.FACEBOOK_WEB_URL)) + } + } + + fun launchGithub(view: View?) { + val intent: Intent + try { + intent = Intent(Intent.ACTION_VIEW, Uri.parse(Urls.GITHUB_REPO_URL)) + intent.setPackage(Urls.GITHUB_PACKAGE_NAME) + startActivity(intent) + } catch (e: Exception) { + Utils.handleWebUrl(this, Uri.parse(Urls.GITHUB_REPO_URL)) + } + } + + fun launchWebsite(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.WEBSITE_URL)) + } + + fun launchRatings(view: View?) { + Utils.rateApp(this) + } + + fun launchCredits(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.CREDITS_URL)) + } + + fun launchUserGuide(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.USER_GUIDE_URL)) + } + + fun launchPrivacyPolicy(view: View?) { + Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL)) + } + + fun launchFrequentlyAskedQuesions(view: View?) { + Utils.handleWebUrl(this, Uri.parse(Urls.FAQ_URL)) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_about, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.share_app_icon -> { + val shareText = String.format( + getString(R.string.share_text), + Urls.PLAY_STORE_URL_PREFIX + this.packageName + ) + val sendIntent = Intent() + sendIntent.setAction(Intent.ACTION_SEND) + sendIntent.putExtra(Intent.EXTRA_TEXT, shareText) + sendIntent.setType("text/plain") + startActivity(Intent.createChooser(sendIntent, getString(R.string.share_via))) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun launchTranslate(view: View?) { + val sortedLocalizedNamesRef = instance.languageLookUpTable!!.getCanonicalNames() + Collections.sort(sortedLocalizedNamesRef) + val languageAdapter = ArrayAdapter( + this@AboutActivity, + android.R.layout.simple_spinner_dropdown_item, sortedLocalizedNamesRef + ) + val spinner = Spinner(this@AboutActivity) + spinner.layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + spinner.setPadding(50, 0, 0, 0) + + val positiveButtonRunnable = Runnable { + val langCode = instance.languageLookUpTable!!.getCodes()[spinner.selectedItemPosition] + Utils.handleWebUrl(this@AboutActivity, Uri.parse(Urls.TRANSLATE_WIKI_URL + langCode)) + } + showAlertDialog( + this, + getString(R.string.about_translate_title), + getString(R.string.about_translate_message), + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + positiveButtonRunnable, + {}, + spinner + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt index 9ed19d686..90ab0393a 100644 --- a/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt +++ b/app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt @@ -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) } diff --git a/app/src/main/java/fr/free/nrw/commons/Media.kt b/app/src/main/java/fr/free/nrw/commons/Media.kt index 025302cfd..293321c27 100644 --- a/app/src/main/java/fr/free/nrw/commons/Media.kt +++ b/app/src/main/java/fr/free/nrw/commons/Media.kt @@ -90,6 +90,41 @@ class Media constructor( captions = captions, ) + constructor( + captions: Map, + categories: List?, + 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 = emptyMap(), + depictionIds: List = emptyList(), + categoriesHiddenStatus: Map = 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 diff --git a/app/src/main/java/fr/free/nrw/commons/Utils.java b/app/src/main/java/fr/free/nrw/commons/Utils.java index cd9c6eed5..8d0f8b530 100644 --- a/app/src/main/java/fr/free/nrw/commons/Utils.java +++ b/app/src/main/java/fr/free/nrw/commons/Utils.java @@ -148,13 +148,27 @@ public class Utils { } /** - * Util function to handle geo coordinates - * It no longer depends on google maps and any app capable of handling the map intent can handle it - * @param context - * @param latLng + * Util function to handle geo coordinates. It no longer depends on google maps and any app + * capable of handling the map intent can handle it + * + * @param context The context for launching intent + * @param latLng The latitude and longitude of the location */ - public static void handleGeoCoordinates(Context context, LatLng latLng) { - Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri()); + public static void handleGeoCoordinates(final Context context, final LatLng latLng) { + handleGeoCoordinates(context, latLng, 16); + } + + /** + * Util function to handle geo coordinates with specified zoom level. It no longer depends on + * google maps and any app capable of handling the map intent can handle it + * + * @param context The context for launching intent + * @param latLng The latitude and longitude of the location + * @param zoomLevel The zoom level + */ + public static void handleGeoCoordinates(final Context context, final LatLng latLng, + final double zoomLevel) { + final Intent mapIntent = new Intent(Intent.ACTION_VIEW, latLng.getGmmIntentUri(zoomLevel)); if (mapIntent.resolveActivity(context.getPackageManager()) != null) { context.startActivity(mapIntent); } else { diff --git a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt index 0583ae2f9..b7951adab 100644 --- a/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/activity/SingleWebViewActivity.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.ActivityLogoutListener import fr.free.nrw.commons.R import fr.free.nrw.commons.di.ApplicationlessInjection import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar @@ -85,7 +87,12 @@ class SingleWebViewActivity : ComponentActivity() { url = url, successUrl = successUrl, onSuccess = { - // TODO Redirect the user to login screen like we do when the user logout's + //Redirect the user to login screen like we do when the user logout's + val app = applicationContext as CommonsApplication + app.clearApplicationData( + applicationContext, + ActivityLogoutListener(activity = this, ctx = applicationContext) + ) finish() }, modifier = Modifier diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java deleted file mode 100644 index 8c9b559d4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsContentProvider.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java deleted file mode 100644 index 6e4c17c2e..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.java +++ /dev/null @@ -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 loadFavoritesLocations() { - return bookmarkLocationDao.getAllBookmarksLocations(); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt new file mode 100644 index 000000000..81ec80214 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsController.kt @@ -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 = + bookmarkLocationDao.getAllBookmarksLocationsPlace() +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java deleted file mode 100644 index fe4f603f4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.java +++ /dev/null @@ -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 clientProvider; - - @Inject - public BookmarkLocationsDao(@Named("bookmarksLocation") Provider clientProvider) { - this.clientProvider = clientProvider; - } - - /** - * Find all persisted locations bookmarks on database - * - * @return list of Place - */ - @NonNull - public List getAllBookmarksLocations() { - List 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); - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt new file mode 100644 index 000000000..2fa65b2d9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsDao.kt @@ -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 + + /** + * 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 { + return getAllBookmarksLocations().map { it.toPlace() } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java deleted file mode 100644 index f5ce556c4..000000000 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.java +++ /dev/null @@ -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 cameraPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { - contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks); - }); - }); - - private final ActivityResultLauncher galleryPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> { - contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks); - }); - }); - - private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback>() { - @Override - public void onActivityResult(Map result) { - boolean areAllGranted = true; - for(final boolean b : result.values()) { - areAllGranted = areAllGranted && b; - } - - if (areAllGranted) { - 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 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; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt new file mode 100644 index 000000000..4ab21462c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsFragment.kt @@ -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> + 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 + if(view != null) { + viewLifecycleOwner.lifecycleScope.launch { + places = controller.loadFavoritesLocations() + updateUIList(places) + } + } + } + + private fun updateUIList(places: List) { + 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 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt new file mode 100644 index 000000000..b22723c0f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarkLocationsViewModel.kt @@ -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 { +// return bookmarkLocationsDao.getAllBookmarksLocationsPlace() +// } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt new file mode 100644 index 000000000..66d670169 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/locations/BookmarksLocations.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt index fd90be95f..47147944c 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoriesModel.kt @@ -36,37 +36,35 @@ class CategoriesModel * @return */ fun isSpammyCategory(item: String): Boolean { - // Check for current and previous year to exclude these categories from removal - val now = Calendar.getInstance() - val curYear = now[Calendar.YEAR] - val curYearInString = curYear.toString() - val prevYear = curYear - 1 - val prevYearInString = prevYear.toString() - Timber.d("Previous year: %s", prevYearInString) - - val mentionsDecade = item.matches(".*0s.*".toRegex()) - val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) - val spammyCategory = - item.matches("(.*)needing(.*)".toRegex()) || - item.matches("(.*)taken on(.*)".toRegex()) // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) + val spammyCategory = item.matches("(.*)needing(.*)".toRegex()) + || item.matches("(.*)taken on(.*)".toRegex()) + + // checks for + // dd/mm/yyyy or yy + // yyyy or yy/mm/dd + // yyyy or yy/mm + // mm/yyyy or yy + // for `yy` it is assumed that 20XX is implicit. + // with separators [., /, -] + val isIrrelevantCategory = + item.contains("""\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d{2,4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{2,4}[-/.]\d{1,2}|\d{1,2}[-/.]\d{2,4}""".toRegex()) + + if (spammyCategory) { return true } - if (mentionsDecade) { - // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 - // Example: "2020s" is OK, but "1920s" is not (and should be skipped) - return !recentDecade - } else { - // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year - // anywhere within the string (.* is wildcard) (Issue #47) - // And that item does not equal the current year or previous year - return item.matches(".*(19|20)\\d{2}.*".toRegex()) && - !item.contains(curYearInString) && - !item.contains(prevYearInString) + if(isIrrelevantCategory){ + return true } + + val hasYear = item.matches("(.*\\d{4}.*)".toRegex()) + val validYearsRange = item.matches(".*(20[0-9]{2}).*".toRegex()) + + // finally if there's 4 digits year exists in XXXX it should only be in 20XX range. + return hasYear && !validYearsRange } /** diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java deleted file mode 100644 index 65604a7e0..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionController.java +++ /dev/null @@ -1,405 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - -import android.Manifest.permission; -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.widget.Toast; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.lifecycle.LiveData; -import androidx.paging.DataSource.Factory; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.filepicker.DefaultCallback; -import fr.free.nrw.commons.filepicker.FilePicker; -import fr.free.nrw.commons.filepicker.FilePicker.ImageSource; -import fr.free.nrw.commons.filepicker.UploadableFile; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.location.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.upload.UploadActivity; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; - -@Singleton -public class ContributionController { - - public static final String ACTION_INTERNAL_UPLOADS = "internalImageUploads"; - private final JsonKvStore defaultKvStore; - private LatLng locationBeforeImageCapture; - private boolean isInAppCameraUpload; - public LocationPermissionCallback locationPermissionCallback; - private LocationPermissionsHelper locationPermissionsHelper; - // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847] - // LiveData> failedAndPendingContributionList; - LiveData> pendingContributionList; - LiveData> failedContributionList; - - @Inject - LocationServiceManager locationManager; - - @Inject - ContributionsRepository repository; - - @Inject - public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) { - this.defaultKvStore = defaultKvStore; - } - - /** - * Check for permissions and initiate camera click - */ - public void initiateCameraPick(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); - if (!useExtStorage) { - initiateCameraUpload(activity, resultLauncher); - return; - } - - PermissionUtils.checkPermissionsAndPerformAction(activity, - () -> { - if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { - defaultKvStore.putBoolean("inAppCameraFirstRun", false); - askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher); - } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - }, - R.string.storage_permission_title, - R.string.write_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE()); - } - - /** - * Asks users to provide location access - * - * @param activity - */ - private void createDialogsAndHandleLocationPermissions(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - locationPermissionCallback = new LocationPermissionCallback() { - @Override - public void onLocationPermissionDenied(String toastMessage) { - Toast.makeText( - activity, - toastMessage, - Toast.LENGTH_LONG - ).show(); - initiateCameraUpload(activity, resultLauncher); - } - - @Override - public void onLocationPermissionGranted() { - if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { - showLocationOffDialog(activity, R.string.in_app_camera_needs_location, - R.string.in_app_camera_location_unavailable, resultLauncher); - } else { - initiateCameraUpload(activity, resultLauncher); - } - } - }; - - locationPermissionsHelper = new LocationPermissionsHelper( - activity, locationManager, locationPermissionCallback); - if (inAppCameraLocationPermissionLauncher != null) { - inAppCameraLocationPermissionLauncher.launch( - new String[]{permission.ACCESS_FINE_LOCATION}); - } - - } - - /** - * Shows a dialog alerting the user about location services being off and asking them to turn it - * on - * TODO: Add a seperate callback in LocationPermissionsHelper for this. - * Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114 - * - * @param activity Activity reference - * @param dialogTextResource Resource id of text to be shown in dialog - * @param toastTextResource Resource id of text to be shown in toast - * @param resultLauncher - */ - private void showLocationOffDialog(Activity activity, int dialogTextResource, - int toastTextResource, ActivityResultLauncher resultLauncher) { - DialogUtil - .showAlertDialog(activity, - activity.getString(R.string.ask_to_turn_location_on), - activity.getString(dialogTextResource), - activity.getString(R.string.title_app_shortcut_setting), - activity.getString(R.string.cancel), - () -> locationPermissionsHelper.openLocationSettings(activity), - () -> { - Toast.makeText(activity, activity.getString(toastTextResource), - Toast.LENGTH_LONG).show(); - initiateCameraUpload(activity, resultLauncher); - } - ); - } - - public void handleShowRationaleFlowCameraLocation(Activity activity, - ActivityResultLauncher inAppCameraLocationPermissionLauncher, - ActivityResultLauncher resultLauncher) { - DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), - activity.getString(R.string.in_app_camera_location_permission_rationale), - activity.getString(android.R.string.ok), - activity.getString(android.R.string.cancel), - () -> { - createDialogsAndHandleLocationPermissions(activity, - inAppCameraLocationPermissionLauncher, resultLauncher); - }, - () -> locationPermissionCallback.onLocationPermissionDenied( - activity.getString(R.string.in_app_camera_location_permission_denied)), - null - ); - } - - /** - * Suggest user to attach location information with pictures. If the user selects "Yes", then: - *

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

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

- * When the app is terminated or the device is restarted, contributions remain in the - * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, - * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the - * list of uploads that appear as stuck on opening the app again - */ - @SuppressLint("CheckResult") - private void checkAndResumeStuckUploads() { - List stuckUploads = contributionDao.getContribution( - Collections.singletonList(Contribution.STATE_IN_PROGRESS)) - .subscribeOn(Schedulers.io()) - .blockingGet(); - Timber.d("Resuming " + stuckUploads.size() + " uploads..."); - if (!stuckUploads.isEmpty()) { - for (Contribution contribution : stuckUploads) { - contribution.setState(Contribution.STATE_QUEUED); - contribution.setDateUploadStarted(Calendar.getInstance().getTime()); - Completable.fromAction(() -> contributionDao.saveSynchronous(contribution)) - .subscribeOn(Schedulers.io()) - .subscribe(); - } - WorkRequestHelper.Companion.makeOneTimeWorkRequest( - this, ExistingWorkPolicy.APPEND_OR_REPLACE); - } - } - - @Override - protected void onPostCreate(@Nullable Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - //quizChecker.initQuizCheck(this); - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("viewPagerCurrentItem", binding.pager.getCurrentItem()); - outState.putString("activeFragment", activeFragment.name()); - } - - @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - String activeFragmentName = savedInstanceState.getString("activeFragment"); - if (activeFragmentName != null) { - restoreActiveFragment(activeFragmentName); - } - } - - private void restoreActiveFragment(@NonNull String fragmentName) { - if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) { - setTitle(getString(R.string.contributions_fragment)); - loadFragment(ContributionsFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.NEARBY.name())) { - setTitle(getString(R.string.nearby_fragment)); - loadFragment(NearbyParentFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) { - setTitle(getString(R.string.navigation_item_explore)); - loadFragment(ExploreFragment.newInstance(), false); - } else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) { - setTitle(getString(R.string.bookmarks)); - loadFragment(BookmarkFragment.newInstance(), false); - } - } - - @Override - public void onBackPressed() { - if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { - // Means that contribution fragment is visible - if (!contributionsFragment.backButtonClicked()) {//If this one does not wan't to handle - // the back press, let the activity do so - super.onBackPressed(); - } - } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { - // Means that nearby fragment is visible - /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is - not expanded. So if the back button is pressed, then go back to the Contributions tab */ - if (!nearbyParentFragment.backButtonClicked()) { - getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment) - .commit(); - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { - // Means that explore fragment is visible - if (!exploreFragment.onBackPressed()) { - if (applicationKvStore.getBoolean("login_skipped")) { - super.onBackPressed(); - } else { - setSelectedItemId(NavTab.CONTRIBUTIONS.code()); - } - } - } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { - // Means that bookmark fragment is visible - bookmarkFragment.onBackPressed(); - } else { - super.onBackPressed(); - } - } - - @Override - public void onBackStackChanged() { - //initBackButton(); - } - - /** - * Retry all failed uploads as soon as the user returns to the app - */ - @SuppressLint("CheckResult") - private void retryAllFailedUploads() { - contributionDao. - getContribution(Collections.singletonList(Contribution.STATE_FAILED)) - .subscribeOn(Schedulers.io()) - .subscribe(failedUploads -> { - for (Contribution contribution : failedUploads) { - contributionsFragment.retryUpload(contribution); - } - }); - } - - /** - * Handles item selection in the options menu. This method is called when a user interacts with - * the options menu in the Top Bar. - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.upload_tab: - startActivity(new Intent(this, UploadProgressActivity.class)); - return true; - case R.id.notifications: - // Starts notification activity on click to notification icon - NotificationActivity.Companion.startYourself(this, "unread"); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void centerMapToPlace(Place place) { - setSelectedItemId(NavTab.NEARBY.code()); - nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback( - new NearbyParentFragmentInstanceReadyCallback() { - @Override - public void onReady() { - nearbyParentFragment.centerMapToPlace(place); - } - }); - } - - @Override - protected void onResume() { - super.onResume(); - - if ((applicationKvStore.getBoolean("firstrun", true)) && - (!applicationKvStore.getBoolean("login_skipped"))) { - defaultKvStore.putBoolean("inAppCameraFirstRun", true); - WelcomeActivity.startYourself(this); - } - - retryAllFailedUploads(); - } - - @Override - protected void onDestroy() { - quizChecker.cleanup(); - locationManager.unregisterLocationManager(); - // Remove ourself from hashmap to prevent memory leaks - locationManager = null; - super.onDestroy(); - } - - /** - * Public method to show nearby from the reference of this. - */ - public void showNearby() { - binding.fragmentMainNavTabLayout.setSelectedItemId(NavTab.NEARBY.code()); - } - - public enum ActiveFragment { - CONTRIBUTIONS, - NEARBY, - EXPLORE, - BOOKMARK, - MORE - } - - /** - * Load default language in onCreate from SharedPreferences - */ - private void loadLocale() { - final SharedPreferences preferences = getSharedPreferences("Settings", - Activity.MODE_PRIVATE); - final String language = preferences.getString("language", ""); - final SettingsFragment settingsFragment = new SettingsFragment(); - settingsFragment.setLocale(this, language); - } - - public NavTabLayout.OnNavigationItemSelectedListener getNavListener() { - return navListener; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt new file mode 100644 index 000000000..a61567393 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/MainActivity.kt @@ -0,0 +1,569 @@ +package fr.free.nrw.commons.contributions + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.work.ExistingWorkPolicy +import com.google.android.material.bottomnavigation.BottomNavigationView +import fr.free.nrw.commons.R +import fr.free.nrw.commons.WelcomeActivity +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.BookmarkFragment +import fr.free.nrw.commons.contributions.ContributionsFragment.Companion.newInstance +import fr.free.nrw.commons.databinding.MainBinding +import fr.free.nrw.commons.explore.ExploreFragment +import fr.free.nrw.commons.kvstore.JsonKvStore +import fr.free.nrw.commons.location.LocationServiceManager +import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetFragment +import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment +import fr.free.nrw.commons.navtab.NavTab +import fr.free.nrw.commons.navtab.NavTabLayout +import fr.free.nrw.commons.navtab.NavTabLoggedOut +import fr.free.nrw.commons.nearby.Place +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment +import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.NearbyParentFragmentInstanceReadyCallback +import fr.free.nrw.commons.notification.NotificationActivity.Companion.startYourself +import fr.free.nrw.commons.notification.NotificationController +import fr.free.nrw.commons.quiz.QuizChecker +import fr.free.nrw.commons.settings.SettingsFragment +import fr.free.nrw.commons.theme.BaseActivity +import fr.free.nrw.commons.upload.UploadProgressActivity +import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest +import fr.free.nrw.commons.utils.ViewUtilWrapper +import io.reactivex.Completable +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Named + + +class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener { + @JvmField + @Inject + var sessionManager: SessionManager? = null + + @JvmField + @Inject + var controller: ContributionController? = null + + @JvmField + @Inject + var contributionDao: ContributionDao? = null + + private var contributionsFragment: ContributionsFragment? = null + private var nearbyParentFragment: NearbyParentFragment? = null + private var exploreFragment: ExploreFragment? = null + private var bookmarkFragment: BookmarkFragment? = null + @JvmField + var activeFragment: ActiveFragment? = null + private val mediaDetailPagerFragment: MediaDetailPagerFragment? = null + var navListener: BottomNavigationView.OnNavigationItemSelectedListener? = null + private set + + @JvmField + @Inject + var locationManager: LocationServiceManager? = null + + @JvmField + @Inject + var notificationController: NotificationController? = null + + @JvmField + @Inject + var quizChecker: QuizChecker? = null + + @JvmField + @Inject + @Named("default_preferences") + var applicationKvStore: JsonKvStore? = null + + @JvmField + @Inject + var viewUtilWrapper: ViewUtilWrapper? = null + + var menu: Menu? = null + + @JvmField + var binding: MainBinding? = null + + var tabLayout: NavTabLayout? = null + + + override fun onSupportNavigateUp(): Boolean { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + if (!contributionsFragment!!.backButtonClicked()) { + return false + } + } else { + onBackPressed() + showTabs() + } + return true + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = MainBinding.inflate(layoutInflater) + setContentView(binding!!.root) + setSupportActionBar(binding!!.toolbarBinding.toolbar) + tabLayout = binding!!.fragmentMainNavTabLayout + loadLocale() + + binding!!.toolbarBinding.toolbar.setNavigationOnClickListener { view: View? -> + onSupportNavigateUp() + } + /* +"first_edit_depict" is a key for getting information about opening the depiction editor +screen for the first time after opening the app. + +Getting true by the key means the depiction editor screen is opened for the first time +after opening the app. +Getting false by the key means the depiction editor screen is not opened for the first time +after opening the app. + */ + applicationKvStore!!.putBoolean("first_edit_depict", true) + if (applicationKvStore!!.getBoolean("login_skipped") == true) { + title = getString(R.string.navigation_item_explore) + setUpLoggedOutPager() + } else { + if (applicationKvStore!!.getBoolean("firstrun", true)) { + applicationKvStore!!.putBoolean("hasAlreadyLaunchedBigMultiupload", false) + applicationKvStore!!.putBoolean("hasAlreadyLaunchedCategoriesDialog", false) + } + if (savedInstanceState == null) { + //starting a fresh fragment. + // Open Last opened screen if it is Contributions or Nearby, otherwise Contributions + if (applicationKvStore!!.getBoolean("last_opened_nearby")) { + title = getString(R.string.nearby_fragment) + showNearby() + loadFragment(NearbyParentFragment.newInstance(), false) + } else { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } + } + setUpPager() + /** + * Ask the user for media location access just after login + * so that location in the EXIF metadata of the images shared by the user + * is retained on devices running Android 10 or above + */ +// if (VERSION.SDK_INT >= VERSION_CODES.Q) { +// ActivityCompat.requestPermissions(this, +// new String[]{Manifest.permission.ACCESS_MEDIA_LOCATION}, 0); +// PermissionUtils.checkPermissionsAndPerformAction( +// this, +// () -> {}, +// R.string.media_location_permission_denied, +// R.string.add_location_manually, +// permission.ACCESS_MEDIA_LOCATION); +// } + checkAndResumeStuckUploads() + } + } + + fun setSelectedItemId(id: Int) { + binding!!.fragmentMainNavTabLayout.selectedItemId = id + } + + private fun setUpPager() { + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener( + BottomNavigationView.OnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + // set last_opened_nearby true if item is nearby screen else set false + applicationKvStore!!.putBoolean( + "last_opened_nearby", + item.title == getString(R.string.nearby_fragment) + ) + val fragment = NavTab.of(item.order).newInstance() + loadFragment(fragment, true) + }.also { navListener = it }) + } + + private fun setUpLoggedOutPager() { + loadFragment(ExploreFragment.newInstance(), false) + binding!!.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener { item: MenuItem -> + if (item.title != getString(R.string.more)) { + // do not change title for more fragment + title = item.title + } + val fragment = + NavTabLoggedOut.of(item.order).newInstance() + loadFragment(fragment, true) + } + } + + private fun loadFragment(fragment: Fragment?, showBottom: Boolean): Boolean { + //showBottom so that we do not show the bottom tray again when constructing + //from the saved instance state. + + freeUpFragments(); + + if (fragment is ContributionsFragment) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + // scroll to top if already on the Contributions tab + contributionsFragment!!.scrollToTop() + return true + } + contributionsFragment = fragment + activeFragment = ActiveFragment.CONTRIBUTIONS + } else if (fragment is NearbyParentFragment) { + if (activeFragment == ActiveFragment.NEARBY) { // Do nothing if same tab + return true + } + nearbyParentFragment = fragment + activeFragment = ActiveFragment.NEARBY + } else if (fragment is ExploreFragment) { + if (activeFragment == ActiveFragment.EXPLORE) { // Do nothing if same tab + return true + } + exploreFragment = fragment + activeFragment = ActiveFragment.EXPLORE + } else if (fragment is BookmarkFragment) { + if (activeFragment == ActiveFragment.BOOKMARK) { // Do nothing if same tab + return true + } + bookmarkFragment = fragment + activeFragment = ActiveFragment.BOOKMARK + } else if (fragment == null && showBottom) { + if (applicationKvStore!!.getBoolean("login_skipped") + == true + ) { // If logged out, more sheet is different + val bottomSheet = MoreBottomSheetLoggedOutFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheetLoggedOut" + ) + } else { + val bottomSheet = MoreBottomSheetFragment() + bottomSheet.show( + supportFragmentManager, + "MoreBottomSheet" + ) + } + } + + if (fragment != null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.fragmentContainer, fragment) + .commit() + return true + } + return false + } + + /** + * loadFragment() overload that supports passing extras to fragments + */ + private fun loadFragment(fragment: Fragment?, showBottom: Boolean, args: Bundle?): Boolean { + if (fragment != null && args != null) { + fragment.arguments = args + } + + return loadFragment(fragment, showBottom) + } + + /** + * Old implementation of loadFragment() was causing memory leaks, due to MainActivity holding + * references to cleared fragments. This function frees up all fragment references. + * + * + * Called in loadFragment() before doing the actual loading. + */ + fun freeUpFragments() { + // free all fragments except contributionsFragment because several tests depend on it. + // hence, contributionsFragment is probably still a leak + nearbyParentFragment = null + exploreFragment = null + bookmarkFragment = null + } + + + fun hideTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.GONE + } + + fun showTabs() { + binding!!.fragmentMainNavTabLayout.visibility = View.VISIBLE + } + + /** + * Adds number of uploads next to tab text "Contributions" then it will look like "Contributions + * (NUMBER)" + * + * @param uploadCount + */ + fun setNumOfUploads(uploadCount: Int) { + if (activeFragment == ActiveFragment.CONTRIBUTIONS) { + title = + resources.getString(R.string.contributions_fragment) + " " + (if (uploadCount != 0) + resources + .getQuantityString( + R.plurals.contributions_subtitle, + uploadCount, uploadCount + ) + else + getString(R.string.contributions_subtitle_zero)) + } + } + + /** + * Resume the uploads that got stuck because of the app being killed or the device being + * rebooted. + * + * + * When the app is terminated or the device is restarted, contributions remain in the + * 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So, + * retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the + * list of uploads that appear as stuck on opening the app again + */ + @SuppressLint("CheckResult") + private fun checkAndResumeStuckUploads() { + val stuckUploads = contributionDao!!.getContribution( + listOf(Contribution.STATE_IN_PROGRESS) + ) + .subscribeOn(Schedulers.io()) + .blockingGet() + Timber.d("Resuming " + stuckUploads.size + " uploads...") + if (!stuckUploads.isEmpty()) { + for (contribution in stuckUploads) { + contribution.state = Contribution.STATE_QUEUED + contribution.dateUploadStarted = Calendar.getInstance().time + Completable.fromAction { contributionDao!!.saveSynchronous(contribution) } + .subscribeOn(Schedulers.io()) + .subscribe() + } + makeOneTimeWorkRequest( + this, ExistingWorkPolicy.APPEND_OR_REPLACE + ) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + //quizChecker.initQuizCheck(this); + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("viewPagerCurrentItem", binding!!.pager.currentItem) + outState.putString("activeFragment", activeFragment!!.name) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + val activeFragmentName = savedInstanceState.getString("activeFragment") + if (activeFragmentName != null) { + restoreActiveFragment(activeFragmentName) + } + } + + private fun restoreActiveFragment(fragmentName: String) { + if (fragmentName == ActiveFragment.CONTRIBUTIONS.name) { + title = getString(R.string.contributions_fragment) + loadFragment(newInstance(), false) + } else if (fragmentName == ActiveFragment.NEARBY.name) { + title = getString(R.string.nearby_fragment) + loadFragment(NearbyParentFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.EXPLORE.name) { + title = getString(R.string.navigation_item_explore) + loadFragment(ExploreFragment.newInstance(), false) + } else if (fragmentName == ActiveFragment.BOOKMARK.name) { + title = getString(R.string.bookmarks) + loadFragment(BookmarkFragment.newInstance(), false) + } + } + + override fun onBackPressed() { + if (contributionsFragment != null && activeFragment == ActiveFragment.CONTRIBUTIONS) { + // Means that contribution fragment is visible + if (!contributionsFragment!!.backButtonClicked()) { //If this one does not wan't to handle + // the back press, let the activity do so + super.onBackPressed() + } + } else if (nearbyParentFragment != null && activeFragment == ActiveFragment.NEARBY) { + // Means that nearby fragment is visible + /* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is + not expanded. So if the back button is pressed, then go back to the Contributions tab */ + if (!nearbyParentFragment!!.backButtonClicked()) { + supportFragmentManager.beginTransaction().remove(nearbyParentFragment!!) + .commit() + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) { + // Means that explore fragment is visible + if (!exploreFragment!!.onBackPressed()) { + if (applicationKvStore!!.getBoolean("login_skipped")) { + super.onBackPressed() + } else { + setSelectedItemId(NavTab.CONTRIBUTIONS.code()) + } + } + } else if (bookmarkFragment != null && activeFragment == ActiveFragment.BOOKMARK) { + // Means that bookmark fragment is visible + bookmarkFragment!!.onBackPressed() + } else { + super.onBackPressed() + } + } + + override fun onBackStackChanged() { + //initBackButton(); + } + + /** + * Retry all failed uploads as soon as the user returns to the app + */ + @SuppressLint("CheckResult") + private fun retryAllFailedUploads() { + contributionDao + ?.getContribution(listOf(Contribution.STATE_FAILED)) + ?.subscribeOn(Schedulers.io()) + ?.subscribe { failedUploads -> + failedUploads.forEach { contribution -> + contributionsFragment?.retryUpload(contribution) + } + } + } + + /** + * Handles item selection in the options menu. This method is called when a user interacts with + * the options menu in the Top Bar. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.upload_tab -> { + startActivity(Intent(this, UploadProgressActivity::class.java)) + return true + } + + R.id.notifications -> { + // Starts notification activity on click to notification icon + startYourself(this, "unread") + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun centerMapToPlace(place: Place?) { + setSelectedItemId(NavTab.NEARBY.code()) + nearbyParentFragment!!.setNearbyParentFragmentInstanceReadyCallback( + object : NearbyParentFragmentInstanceReadyCallback { + override fun onReady() { + nearbyParentFragment!!.centerMapToPlace(place) + } + }) + } + + /** + * Launch the Explore fragment from Nearby fragment. This method is called when a user clicks + * the 'Show in Explore' option in the 3-dots menu in Nearby. + * + * @param zoom current zoom of Nearby map + * @param latitude current latitude of Nearby map + * @param longitude current longitude of Nearby map + */ + fun loadExploreMapFromNearby(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(ExploreFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.EXPLORE.code()) + } + + /** + * Launch the Nearby fragment from Explore fragment. This method is called when a user clicks + * the 'Show in Nearby' option in the 3-dots menu in Explore. + * + * @param zoom current zoom of Explore map + * @param latitude current latitude of Explore map + * @param longitude current longitude of Explore map + */ + fun loadNearbyMapFromExplore(zoom: Double, latitude: Double, longitude: Double) { + val bundle = Bundle() + bundle.putDouble("prev_zoom", zoom) + bundle.putDouble("prev_latitude", latitude) + bundle.putDouble("prev_longitude", longitude) + + loadFragment(NearbyParentFragment.newInstance(), false, bundle) + setSelectedItemId(NavTab.NEARBY.code()) + } + + override fun onResume() { + super.onResume() + + if ((applicationKvStore!!.getBoolean("firstrun", true)) && + (!applicationKvStore!!.getBoolean("login_skipped")) + ) { + defaultKvStore.putBoolean("inAppCameraFirstRun", true) + WelcomeActivity.startYourself(this) + } + + retryAllFailedUploads() + } + + override fun onDestroy() { + quizChecker!!.cleanup() + locationManager!!.unregisterLocationManager() + // Remove ourself from hashmap to prevent memory leaks + locationManager = null + super.onDestroy() + } + + /** + * Public method to show nearby from the reference of this. + */ + fun showNearby() { + binding!!.fragmentMainNavTabLayout.selectedItemId = NavTab.NEARBY.code() + } + + enum class ActiveFragment { + CONTRIBUTIONS, + NEARBY, + EXPLORE, + BOOKMARK, + MORE + } + + /** + * Load default language in onCreate from SharedPreferences + */ + private fun loadLocale() { + val preferences = getSharedPreferences( + "Settings", + MODE_PRIVATE + ) + val language = preferences.getString("language", "")!! + val settingsFragment = SettingsFragment() + settingsFragment.setLocale(this, language) + } + + companion object { + /** + * Consumers should be simply using this method to use this activity. + * + * @param context A Context of the application package implementing this class. + */ + fun startYourself(context: Context) { + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java deleted file mode 100644 index 0f18c300b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java +++ /dev/null @@ -1,126 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.WallpaperManager; -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Build; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import com.facebook.common.executors.CallerThreadExecutor; -import com.facebook.common.references.CloseableReference; -import com.facebook.datasource.DataSource; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.imagepipeline.core.ImagePipeline; -import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber; -import com.facebook.imagepipeline.image.CloseableImage; -import com.facebook.imagepipeline.request.ImageRequest; -import com.facebook.imagepipeline.request.ImageRequestBuilder; -import fr.free.nrw.commons.R; -import timber.log.Timber; - -public class SetWallpaperWorker extends Worker { - - private static final String NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel"; - private static final int NOTIFICATION_ID = 1; - - public SetWallpaperWorker(@NonNull Context context, @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - Context context = getApplicationContext(); - createNotificationChannel(context); - showProgressNotification(context); - - String imageUrl = getInputData().getString("imageUrl"); - if (imageUrl == null) { - return Result.failure(); - } - - ImageRequest imageRequest = ImageRequestBuilder - .newBuilderWithSource(Uri.parse(imageUrl)) - .build(); - - ImagePipeline imagePipeline = Fresco.getImagePipeline(); - final DataSource> - dataSource = imagePipeline.fetchDecodedImage(imageRequest, context); - - dataSource.subscribe(new BaseBitmapDataSubscriber() { - @Override - public void onNewResultImpl(@Nullable Bitmap bitmap) { - if (dataSource.isFinished() && bitmap != null) { - Timber.d("Bitmap loaded from url %s", imageUrl.toString()); - setWallpaper(context, Bitmap.createBitmap(bitmap)); - dataSource.close(); - } - } - - @Override - public void onFailureImpl(DataSource dataSource) { - Timber.d("Error getting bitmap from image url %s", imageUrl.toString()); - showNotification(context, "Setting Wallpaper Failed", "Failed to download image."); - if (dataSource != null) { - dataSource.close(); - } - } - }, CallerThreadExecutor.getInstance()); - - return Result.success(); - } - - private void setWallpaper(Context context, Bitmap bitmap) { - WallpaperManager wallpaperManager = WallpaperManager.getInstance(context); - - try { - wallpaperManager.setBitmap(bitmap); - showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully."); - - } catch (Exception e) { - Timber.e(e, "Error setting wallpaper"); - showNotification(context, "Setting Wallpaper Failed", " "+e.getLocalizedMessage()); - } - } - - private void showProgressNotification(Context context) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle("Setting Wallpaper") - .setContentText("Please wait...") - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(true) - .setProgress(0, 0, true); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void showNotification(Context context, String title, String content) { - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.commons_logo) - .setContentTitle(title) - .setContentText(content) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setOngoing(false); - notificationManager.notify(NOTIFICATION_ID, builder.build()); - } - - private void createNotificationChannel(Context context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - CharSequence name = "Wallpaper Setting"; - String description = "Notifications for wallpaper setting progress"; - int importance = NotificationManager.IMPORTANCE_HIGH; - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance); - channel.setDescription(description); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt new file mode 100644 index 000000000..06c31fede --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.kt @@ -0,0 +1,113 @@ +package fr.free.nrw.commons.contributions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.WallpaperManager +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import fr.free.nrw.commons.R +import timber.log.Timber + +class SetWallpaperWorker(context: Context, params: WorkerParameters) : + Worker(context, params) { + override fun doWork(): Result { + val context = applicationContext + createNotificationChannel(context) + showProgressNotification(context) + + val imageUrl = inputData.getString("imageUrl") ?: return Result.failure() + + val imageRequest = ImageRequestBuilder + .newBuilderWithSource(Uri.parse(imageUrl)) + .build() + + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, context) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + public override fun onNewResultImpl(bitmap: Bitmap?) { + if (dataSource.isFinished && bitmap != null) { + Timber.d("Bitmap loaded from url %s", imageUrl.toString()) + setWallpaper(context, Bitmap.createBitmap(bitmap)) + dataSource.close() + } + } + + override fun onFailureImpl(dataSource: DataSource>?) { + Timber.d("Error getting bitmap from image url %s", imageUrl.toString()) + showNotification(context, "Setting Wallpaper Failed", "Failed to download image.") + dataSource?.close() + } + }, CallerThreadExecutor.getInstance()) + + return Result.success() + } + + private fun setWallpaper(context: Context, bitmap: Bitmap) { + val wallpaperManager = WallpaperManager.getInstance(context) + + try { + wallpaperManager.setBitmap(bitmap) + showNotification(context, "Wallpaper Set", "Wallpaper has been updated successfully.") + } catch (e: Exception) { + Timber.e(e, "Error setting wallpaper") + showNotification(context, "Setting Wallpaper Failed", " " + e.localizedMessage) + } + } + + private fun showProgressNotification(context: Context) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle("Setting Wallpaper") + .setContentText("Please wait...") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setProgress(0, 0, true) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun showNotification(context: Context, title: String, content: String) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.commons_logo) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name: CharSequence = "Wallpaper Setting" + val description = "Notifications for wallpaper setting progress" + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance) + channel.description = description + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + } + } + + companion object { + private const val NOTIFICATION_CHANNEL_ID = "set_wallpaper_channel" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java deleted file mode 100644 index 898a36a99..000000000 --- a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.java +++ /dev/null @@ -1,31 +0,0 @@ -package fr.free.nrw.commons.contributions; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.viewpager.widget.ViewPager; - -public class UnswipableViewPager extends ViewPager{ - public UnswipableViewPager(@NonNull Context context) { - super(context); - } - - public UnswipableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent event) { - // Unswipable - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - // Unswipable - return false; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt new file mode 100644 index 000000000..dd6ae661a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/contributions/UnswipableViewPager.kt @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.contributions + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.viewpager.widget.ViewPager + +class UnswipableViewPager : ViewPager { + constructor(context: Context) : super(context) + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + // Unswipable + return false + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt index 86cda2cf3..f16a48b4c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/WikipediaInstructionsDialogFragment.kt @@ -43,7 +43,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { /** * Callback for handling confirm button clicked */ - interface Callback { + fun interface Callback { fun onConfirmClicked( contribution: Contribution?, copyWikicode: Boolean, diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt index 1377ae281..7cb7f60f7 100644 --- a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt @@ -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) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt index 0c34bbdec..74ec9bc89 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt index 94319060b..9e569982f 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationComponent.kt @@ -9,6 +9,7 @@ import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.activity.SingleWebViewActivity import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.contributions.ContributionsModule +import fr.free.nrw.commons.contributions.ContributionsProvidesModule import fr.free.nrw.commons.explore.SearchModule import fr.free.nrw.commons.explore.categories.CategoriesModule import fr.free.nrw.commons.explore.depictions.DepictionModule @@ -40,6 +41,7 @@ import javax.inject.Singleton ContentProviderBuilderModule::class, UploadModule::class, ContributionsModule::class, + ContributionsProvidesModule::class, SearchModule::class, DepictionModule::class, CategoriesModule::class diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt index 58d9039d5..85af7f6eb 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt @@ -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() + } + } } } diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt index 8204d4415..5468cfa10 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsDaggerSupportFragment.kt @@ -15,8 +15,8 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje @Inject @JvmField var childFragmentInjector: DispatchingAndroidInjector? = null - @JvmField - protected var compositeDisposable: CompositeDisposable = CompositeDisposable() + // Removed @JvmField to allow overriding + protected open var compositeDisposable: CompositeDisposable = CompositeDisposable() override fun onAttach(context: Context) { inject() @@ -63,4 +63,9 @@ abstract class CommonsDaggerSupportFragment : Fragment(), HasSupportFragmentInje return getInstance(activity.applicationContext) } + + // Ensure getContext() returns a non-null Context + override fun getContext(): Context { + return super.getContext() ?: throw IllegalStateException("Context is null") + } } diff --git a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt index 1882f77a9..80a0626cc 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/ContentProviderBuilderModule.kt @@ -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 diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java index d444148d4..223d028dc 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreFragment.java @@ -1,5 +1,7 @@ package fr.free.nrw.commons.explore; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; + import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; @@ -42,9 +44,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { @Named("default_preferences") public JsonKvStore applicationKvStore; - public void setScroll(boolean canScroll){ - if (binding != null) - { + // Nearby map state (for if we came from Nearby fragment) + private double prevZoom; + private double prevLatitude; + private double prevLongitude; + + public void setScroll(boolean canScroll) { + if (binding != null) { binding.viewPager.setCanScroll(canScroll); } } @@ -60,6 +66,7 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + loadNearbyMapData(); binding = FragmentExploreBinding.inflate(inflater, container, false); viewPagerAdapter = new ViewPagerAdapter(getChildFragmentManager()); @@ -89,6 +96,11 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { }); setTabs(); setHasOptionsMenu(true); + + // if we came from 'Show in Explore' in Nearby, jump to Map tab + if (isCameFromNearbyMap()) { + binding.viewPager.setCurrentItem(2); + } return binding.getRoot(); } @@ -108,6 +120,13 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { Bundle mapArguments = new Bundle(); mapArguments.putString("categoryName", EXPLORE_MAP); + // if we came from 'Show in Explore' in Nearby, pass on zoom and center to Explore map root + if (isCameFromNearbyMap()) { + mapArguments.putDouble("prev_zoom", prevZoom); + mapArguments.putDouble("prev_latitude", prevLatitude); + mapArguments.putDouble("prev_longitude", prevLongitude); + } + featuredRootFragment = new ExploreListRootFragment(featuredArguments); mobileRootFragment = new ExploreListRootFragment(mobileArguments); mapRootFragment = new ExploreMapRootFragment(mapArguments); @@ -120,13 +139,35 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { fragmentList.add(mapRootFragment); titleList.add(getString(R.string.explore_tab_title_map).toUpperCase(Locale.ROOT)); - ((MainActivity)getActivity()).showTabs(); + ((MainActivity) getActivity()).showTabs(); ((BaseActivity) getActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(false); viewPagerAdapter.setTabData(fragmentList, titleList); viewPagerAdapter.notifyDataSetChanged(); } + /** + * Fetch Nearby map camera data from fragment arguments if any. + */ + public void loadNearbyMapData() { + // get fragment arguments + if (getArguments() != null) { + prevZoom = getArguments().getDouble("prev_zoom"); + prevLatitude = getArguments().getDouble("prev_latitude"); + prevLongitude = getArguments().getDouble("prev_longitude"); + } + } + + /** + * Checks if fragment arguments contain data from Nearby map. if present, then the user + * navigated from Nearby using 'Show in Explore'. + * + * @return true if user navigated from Nearby map + **/ + public boolean isCameFromNearbyMap() { + return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0; + } + public boolean onBackPressed() { if (binding.tabLayout.getSelectedTabPosition() == 0) { if (featuredRootFragment.backPressed()) { @@ -155,7 +196,38 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { */ @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_search, menu); + // if logged in 'Show in Nearby' menu item is visible + if (applicationKvStore.getBoolean("login_skipped") == false) { + inflater.inflate(R.menu.explore_fragment_menu, menu); + + MenuItem others = menu.findItem(R.id.list_item_show_in_nearby); + + if (binding.viewPager.getCurrentItem() == 2) { + others.setVisible(true); + } + + // if on Map tab, show all menu options, else only show search + binding.viewPager.addOnPageChangeListener(new OnPageChangeListener() { + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + others.setVisible((position == 2)); + } + + @Override + public void onPageScrollStateChanged(int state) { + if (state == SCROLL_STATE_IDLE && binding.viewPager.getCurrentItem() == 2) { + onPageSelected(2); + } + } + }); + } else { + inflater.inflate(R.menu.menu_search, menu); + } super.onCreateOptionsMenu(menu, inflater); } @@ -171,6 +243,9 @@ public class ExploreFragment extends CommonsDaggerSupportFragment { case R.id.action_search: ActivityUtils.startActivityWithFlags(getActivity(), SearchActivity.class); return true; + case R.id.list_item_show_in_nearby: + mapRootFragment.loadNearbyMapFromExplore(); + return true; default: return super.onOptionsItemSelected(item); } diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java index 2653b4409..abf02758d 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreMapRootFragment.java @@ -39,10 +39,22 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme } public ExploreMapRootFragment(Bundle bundle) { + // get fragment arguments String title = bundle.getString("categoryName"); + double zoom = bundle.getDouble("prev_zoom"); + double latitude = bundle.getDouble("prev_latitude"); + double longitude = bundle.getDouble("prev_longitude"); + mapFragment = new ExploreMapFragment(); Bundle featuredArguments = new Bundle(); featuredArguments.putString("categoryName", title); + + // if we came from 'Show in Explore' in Nearby, pass on zoom and center + if (zoom != 0.0 || latitude != 0.0 || longitude != 0.0) { + featuredArguments.putDouble("prev_zoom", zoom); + featuredArguments.putDouble("prev_latitude", latitude); + featuredArguments.putDouble("prev_longitude", longitude); + } mapFragment.setArguments(featuredArguments); } @@ -198,7 +210,8 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme ((MainActivity) getActivity()).showTabs(); return true; - } if (mapFragment != null && mapFragment.isVisible()) { + } + if (mapFragment != null && mapFragment.isVisible()) { if (mapFragment.backButtonClicked()) { // Explore map fragment handled the event no further action required. return true; @@ -213,6 +226,10 @@ public class ExploreMapRootFragment extends CommonsDaggerSupportFragment impleme return false; } + public void loadNearbyMapFromExplore() { + mapFragment.loadNearbyMapFromExplore(); + } + @Override public void onDestroy() { super.onDestroy(); diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index fd1ea1f28..1b1659182 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -38,6 +38,7 @@ import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao; +import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.databinding.FragmentExploreMapBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.ExploreMapRootFragment; @@ -115,6 +116,11 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment SystemThemeUtils systemThemeUtils; LocationPermissionsHelper locationPermissionsHelper; + // Nearby map state (if we came from Nearby) + private double prevZoom; + private double prevLatitude; + private double prevLongitude; + private ExploreMapPresenter presenter; public FragmentExploreMapBinding binding; @@ -160,6 +166,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment ViewGroup container, Bundle savedInstanceState ) { + loadNearbyMapData(); binding = FragmentExploreMapBinding.inflate(getLayoutInflater()); return binding.getRoot(); } @@ -169,12 +176,14 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment super.onViewCreated(view, savedInstanceState); setSearchThisAreaButtonVisibility(false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); + binding.tvAttribution.setText( + Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); } else { binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); } initNetworkBroadCastReceiver(); - locationPermissionsHelper = new LocationPermissionsHelper(getActivity(),locationManager,this); + locationPermissionsHelper = new LocationPermissionsHelper(getActivity(), locationManager, + this); if (presenter == null) { presenter = new ExploreMapPresenter(bookmarkLocationDao); } @@ -204,9 +213,14 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment scaleBarOverlay.setBackgroundPaint(barPaint); scaleBarOverlay.enableScaleBar(); binding.mapView.getOverlays().add(scaleBarOverlay); - binding.mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); + binding.mapView.getZoomController() + .setVisibility(CustomZoomButtonsController.Visibility.NEVER); binding.mapView.setMultiTouchControls(true); - binding.mapView.getController().setZoom(ZOOM_LEVEL); + + if (!isCameFromNearbyMap()) { + binding.mapView.getController().setZoom(ZOOM_LEVEL); + } + performMapReadyActions(); binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { @@ -295,7 +309,7 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment unregisterNetworkReceiver(); } - + /** * Unregisters the networkReceiver */ @@ -328,11 +342,51 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment isPermissionDenied = true; } lastKnownLocation = MapUtils.getDefaultLatLng(); - moveCameraToPosition( - new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); + + // if we came from 'Show in Explore' in Nearby, load Nearby map center and zoom + if (isCameFromNearbyMap()) { + moveCameraToPosition( + new GeoPoint(prevLatitude, prevLongitude), + prevZoom, + 1L + ); + } else { + moveCameraToPosition( + new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); + } presenter.onMapReady(exploreMapController); } + /** + * Fetch Nearby map camera data from fragment arguments if any. + */ + public void loadNearbyMapData() { + // get fragment arguments + if (getArguments() != null) { + prevZoom = getArguments().getDouble("prev_zoom"); + prevLatitude = getArguments().getDouble("prev_latitude"); + prevLongitude = getArguments().getDouble("prev_longitude"); + } + } + + /** + * Checks if fragment arguments contain data from Nearby map, indicating that the user navigated + * from Nearby using 'Show in Explore'. + * + * @return true if user navigated from Nearby map + **/ + public boolean isCameFromNearbyMap() { + return prevZoom != 0.0 || prevLatitude != 0.0 || prevLongitude != 0.0; + } + + public void loadNearbyMapFromExplore() { + ((MainActivity) getContext()).loadNearbyMapFromExplore( + binding.mapView.getZoomLevelDouble(), + binding.mapView.getMapCenter().getLatitude(), + binding.mapView.getMapCenter().getLongitude() + ); + } + private void initViews() { Timber.d("init views called"); initBottomSheets(); @@ -346,7 +400,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment */ @SuppressLint("ClickableViewAccessibility") private void initBottomSheets() { - bottomSheetDetailsBehavior = BottomSheetBehavior.from(binding.bottomSheetDetailsBinding.getRoot()); + bottomSheetDetailsBehavior = BottomSheetBehavior.from( + binding.bottomSheetDetailsBinding.getRoot()); bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); binding.bottomSheetDetailsBinding.getRoot().setVisibility(View.VISIBLE); } @@ -404,23 +459,25 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment if (currentLatLng == null) { return; } - if (currentLatLng.equals(getLastMapFocus())) { // Means we are checking around current location + if (currentLatLng.equals( + getLastMapFocus())) { // Means we are checking around current location nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(currentLatLng, getLastMapFocus(), true); } else { nearbyPlacesInfoObservable = presenter.loadAttractionsFromLocation(getLastMapFocus(), currentLatLng, false); } - compositeDisposable.add(nearbyPlacesInfoObservable + getCompositeDisposable().add(nearbyPlacesInfoObservable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(explorePlacesInfo -> { mediaList = explorePlacesInfo.mediaList; - if(mediaList == null) { + if (mediaList == null) { showResponseMessage(getString(R.string.no_pictures_in_this_area)); } updateMapMarkers(explorePlacesInfo); - lastMapFocus = new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude()); + lastMapFocus = new GeoPoint(currentLatLng.getLatitude(), + currentLatLng.getLongitude()); }, throwable -> { Timber.d(throwable); @@ -474,9 +531,9 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER); locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); setProgressBarVisibility(true); - } - else { - locationPermissionsHelper.showLocationOffDialog(getActivity(), R.string.ask_to_turn_location_on_text); + } else { + locationPermissionsHelper.showLocationOffDialog(getActivity(), + R.string.ask_to_turn_location_on_text); } presenter.onMapReady(exploreMapController); registerUnregisterLocationListener(false); @@ -508,7 +565,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment recenterToUserLocation = true; return; } - recenterMarkerToPosition(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); + recenterMarkerToPosition( + new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); binding.mapView.getController() .animateTo(new GeoPoint(currentLatLng.getLatitude(), currentLatLng.getLongitude())); if (lastMapFocus != null) { @@ -545,10 +603,12 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment * @param place Place of clicked nearby marker */ private void passInfoToSheet(final Place place) { - binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener(view -> Utils.handleGeoCoordinates(getActivity(), - place.getLocation())); + binding.bottomSheetDetailsBinding.directionsButton.setOnClickListener( + view -> Utils.handleGeoCoordinates(getActivity(), + place.getLocation(), binding.mapView.getZoomLevelDouble())); - binding.bottomSheetDetailsBinding.commonsButton.setVisibility(place.hasCommonsLink() ? View.VISIBLE : View.GONE); + binding.bottomSheetDetailsBinding.commonsButton.setVisibility( + place.hasCommonsLink() ? View.VISIBLE : View.GONE); binding.bottomSheetDetailsBinding.commonsButton.setOnClickListener( view -> Utils.handleWebUrl(getContext(), place.siteLinks.getCommonsLink())); @@ -562,7 +622,8 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment } index++; } - binding.bottomSheetDetailsBinding.title.setText(place.name.substring(5, place.name.lastIndexOf("."))); + binding.bottomSheetDetailsBinding.title.setText( + place.name.substring(5, place.name.lastIndexOf("."))); binding.bottomSheetDetailsBinding.category.setText(place.distance); // Remove label since it is double information String descriptionText = place.getLongDescription() @@ -640,40 +701,43 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment * @param nearbyBaseMarker The NearbyBaseMarker object representing the marker to be added. */ private void addMarkerToMap(BaseMarker nearbyBaseMarker) { - ArrayList items = new ArrayList<>(); - Bitmap icon = nearbyBaseMarker.getIcon(); - Drawable d = new BitmapDrawable(getResources(), icon); - GeoPoint point = new GeoPoint( - nearbyBaseMarker.getPlace().location.getLatitude(), - nearbyBaseMarker.getPlace().location.getLongitude()); - OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null, - point); - item.setMarker(d); - items.add(item); - ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items, - new OnItemGestureListener() { - @Override - public boolean onItemSingleTapUp(int index, OverlayItem item) { - final Place place = nearbyBaseMarker.getPlace(); - if (clickedMarker != null) { - removeMarker(clickedMarker); - addMarkerToMap(clickedMarker); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + if (isAttachedToActivity()) { + ArrayList items = new ArrayList<>(); + Bitmap icon = nearbyBaseMarker.getIcon(); + Drawable d = new BitmapDrawable(getResources(), icon); + GeoPoint point = new GeoPoint( + nearbyBaseMarker.getPlace().location.getLatitude(), + nearbyBaseMarker.getPlace().location.getLongitude()); + OverlayItem item = new OverlayItem(nearbyBaseMarker.getPlace().name, null, + point); + item.setMarker(d); + items.add(item); + ItemizedOverlayWithFocus overlay = new ItemizedOverlayWithFocus(items, + new OnItemGestureListener() { + @Override + public boolean onItemSingleTapUp(int index, OverlayItem item) { + final Place place = nearbyBaseMarker.getPlace(); + if (clickedMarker != null) { + removeMarker(clickedMarker); + addMarkerToMap(clickedMarker); + bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + bottomSheetDetailsBehavior.setState( + BottomSheetBehavior.STATE_COLLAPSED); + } + clickedMarker = nearbyBaseMarker; + passInfoToSheet(place); + return true; } - clickedMarker = nearbyBaseMarker; - passInfoToSheet(place); - return true; - } - @Override - public boolean onItemLongPress(int index, OverlayItem item) { - return false; - } - }, getContext()); + @Override + public boolean onItemLongPress(int index, OverlayItem item) { + return false; + } + }, getContext()); - overlay.setFocusItemsOnTap(true); - binding.mapView.getOverlays().add(overlay); // Add the overlay to the map + overlay.setFocusItemsOnTap(true); + binding.mapView.getOverlays().add(overlay); // Add the overlay to the map + } } /** @@ -707,68 +771,72 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment */ @Override public void clearAllMarkers() { - binding.mapView.getOverlayManager().clear(); - GeoPoint geoPoint = mapCenter; - if (geoPoint != null) { - List overlays = binding.mapView.getOverlays(); - ScaleDiskOverlay diskOverlay = - new ScaleDiskOverlay(this.getContext(), - geoPoint, 2000, GeoConstants.UnitOfMeasure.foot); - Paint circlePaint = new Paint(); - circlePaint.setColor(Color.rgb(128, 128, 128)); - circlePaint.setStyle(Paint.Style.STROKE); - circlePaint.setStrokeWidth(2f); - diskOverlay.setCirclePaint2(circlePaint); - Paint diskPaint = new Paint(); - diskPaint.setColor(Color.argb(40, 128, 128, 128)); - diskPaint.setStyle(Paint.Style.FILL_AND_STROKE); - diskOverlay.setCirclePaint1(diskPaint); - diskOverlay.setDisplaySizeMin(900); - diskOverlay.setDisplaySizeMax(1700); - binding.mapView.getOverlays().add(diskOverlay); - org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( - binding.mapView); - startMarker.setPosition(geoPoint); - startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, - org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM); - startMarker.setIcon( - ContextCompat.getDrawable(this.getContext(), R.drawable.current_location_marker)); - startMarker.setTitle("Your Location"); - startMarker.setTextLabelFontSize(24); - binding.mapView.getOverlays().add(startMarker); - } - ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView); - scaleBarOverlay.setScaleBarOffset(15, 25); - Paint barPaint = new Paint(); - barPaint.setARGB(200, 255, 250, 250); - scaleBarOverlay.setBackgroundPaint(barPaint); - scaleBarOverlay.enableScaleBar(); - binding.mapView.getOverlays().add(scaleBarOverlay); - binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { - @Override - public boolean singleTapConfirmedHelper(GeoPoint p) { - if (clickedMarker != null) { - removeMarker(clickedMarker); - addMarkerToMap(clickedMarker); - binding.mapView.invalidate(); - } else { - Timber.e("CLICKED MARKER IS NULL"); - } - if (bottomSheetDetailsBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - // Back should first hide the bottom sheet if it is expanded - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } else if (isDetailsBottomSheetVisible()) { - hideBottomDetailsSheet(); - } - return true; + if (isAttachedToActivity()) { + binding.mapView.getOverlayManager().clear(); + GeoPoint geoPoint = mapCenter; + if (geoPoint != null) { + List overlays = binding.mapView.getOverlays(); + ScaleDiskOverlay diskOverlay = + new ScaleDiskOverlay(this.getContext(), + geoPoint, 2000, GeoConstants.UnitOfMeasure.foot); + Paint circlePaint = new Paint(); + circlePaint.setColor(Color.rgb(128, 128, 128)); + circlePaint.setStyle(Paint.Style.STROKE); + circlePaint.setStrokeWidth(2f); + diskOverlay.setCirclePaint2(circlePaint); + Paint diskPaint = new Paint(); + diskPaint.setColor(Color.argb(40, 128, 128, 128)); + diskPaint.setStyle(Paint.Style.FILL_AND_STROKE); + diskOverlay.setCirclePaint1(diskPaint); + diskOverlay.setDisplaySizeMin(900); + diskOverlay.setDisplaySizeMax(1700); + binding.mapView.getOverlays().add(diskOverlay); + org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker( + binding.mapView); + startMarker.setPosition(geoPoint); + startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER, + org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM); + startMarker.setIcon( + ContextCompat.getDrawable(this.getContext(), + R.drawable.current_location_marker)); + startMarker.setTitle("Your Location"); + startMarker.setTextLabelFontSize(24); + binding.mapView.getOverlays().add(startMarker); } + ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.mapView); + scaleBarOverlay.setScaleBarOffset(15, 25); + Paint barPaint = new Paint(); + barPaint.setARGB(200, 255, 250, 250); + scaleBarOverlay.setBackgroundPaint(barPaint); + scaleBarOverlay.enableScaleBar(); + binding.mapView.getOverlays().add(scaleBarOverlay); + binding.mapView.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { + @Override + public boolean singleTapConfirmedHelper(GeoPoint p) { + if (clickedMarker != null) { + removeMarker(clickedMarker); + addMarkerToMap(clickedMarker); + binding.mapView.invalidate(); + } else { + Timber.e("CLICKED MARKER IS NULL"); + } + if (bottomSheetDetailsBehavior.getState() + == BottomSheetBehavior.STATE_EXPANDED) { + // Back should first hide the bottom sheet if it is expanded + bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); + } else if (isDetailsBottomSheetVisible()) { + hideBottomDetailsSheet(); + } + return true; + } - @Override - public boolean longPressHelper(GeoPoint p) { - return false; - } - })); - binding.mapView.setMultiTouchControls(true); + @Override + public boolean longPressHelper(GeoPoint p) { + return false; + } + })); + binding.mapView.setMultiTouchControls(true); + } } /** @@ -825,6 +893,18 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment binding.mapView.getController().animateTo(geoPoint); } + /** + * Moves the camera of the map view to the specified GeoPoint at specified zoom level and speed + * using an animation. + * + * @param geoPoint The GeoPoint representing the new camera position for the map. + * @param zoom Zoom level of the map camera + * @param speed Speed of animation + */ + private void moveCameraToPosition(GeoPoint geoPoint, double zoom, long speed) { + binding.mapView.getController().animateTo(geoPoint, zoom, speed); + } + @Override public fr.free.nrw.commons.location.LatLng getLastMapFocus() { return lastMapFocus == null ? getMapCenter() : new fr.free.nrw.commons.location.LatLng( @@ -850,14 +930,17 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment -0.07483536015053005, 1f); } } - moveCameraToPosition(new GeoPoint(latLnge.getLatitude(),latLnge.getLongitude())); + if (!isCameFromNearbyMap()) { + moveCameraToPosition(new GeoPoint(latLnge.getLatitude(), latLnge.getLongitude())); + } return latLnge; } @Override public fr.free.nrw.commons.location.LatLng getMapFocus() { fr.free.nrw.commons.location.LatLng mapFocusedLatLng = new fr.free.nrw.commons.location.LatLng( - binding.mapView.getMapCenter().getLatitude(), binding.mapView.getMapCenter().getLongitude(), 100); + binding.mapView.getMapCenter().getLatitude(), + binding.mapView.getMapCenter().getLongitude(), 100); return mapFocusedLatLng; } @@ -910,9 +993,19 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment }; } - @Override - public void onLocationPermissionDenied(String toastMessage) {} + /** + * helper function to confirm that this fragment has been attached. + **/ + public boolean isAttachedToActivity() { + boolean attached = isVisible() && getActivity() != null; + return attached; + } @Override - public void onLocationPermissionGranted() {} + public void onLocationPermissionDenied(String toastMessage) { + } + + @Override + public void onLocationPermissionGranted() { + } } diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt index 2ed573740..acf072f02 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -426,7 +426,7 @@ object FilePicker : Constants { fun onCanceled(source: ImageSource, type: Int) } - interface HandleActivityResult { + fun interface HandleActivityResult { fun onHandleActivityResult(callbacks: Callbacks) } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt index 7dd9a49ce..5ca75b38c 100644 --- a/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt +++ b/app/src/main/java/fr/free/nrw/commons/location/LatLng.kt @@ -123,10 +123,13 @@ data class LatLng( /** * Gets a URI for a Google Maps intent at the location. + * + * @paraam zoom The zoom level + * @return URI for the intent */ - fun getGmmIntentUri(): Uri { - return Uri.parse("geo:$latitude,$longitude?z=16") - } + fun getGmmIntentUri(zoom: Double): Uri = Uri.parse( + "geo:$latitude,$longitude?q=$latitude,$longitude&z=${zoom}" + ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeDouble(latitude) diff --git a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt index 080bc058d..2a7b7713b 100644 --- a/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/locationpicker/LocationPickerActivity.kt @@ -430,7 +430,11 @@ class LocationPickerActivity : BaseActivity(), LocationPermissionCallback { else -> null } - position?.let { Utils.handleGeoCoordinates(this, it) } + position?.let { + mapView?.zoomLevelDouble?.let { zoomLevel -> + Utils.handleGeoCoordinates(this, it, zoomLevel) + } ?: Utils.handleGeoCoordinates(this, it) + } } /** diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt index 4c993fb80..77ff1df0c 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -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) { - 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) { 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. diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index 545e96624..b4b4e9c57 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -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"); } } diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt index 8d5298cac..73d030ed0 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/NavTabLayout.kt @@ -31,8 +31,8 @@ class NavTabLayout : BottomNavigationView { private fun setTabViews() { val isLoginSkipped = (context as MainActivity) - .applicationKvStore.getBoolean("login_skipped") - if (isLoginSkipped) { + .applicationKvStore?.getBoolean("login_skipped") + if (isLoginSkipped == true) { for (i in 0 until NavTabLoggedOut.size()) { val navTab = NavTabLoggedOut.of(i) menu.add(Menu.NONE, i, i, navTab.text()).setIcon(navTab.icon()) diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/BottomSheetAdapter.kt b/app/src/main/java/fr/free/nrw/commons/nearby/BottomSheetAdapter.kt index 8bcc21e40..a83d49f75 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/BottomSheetAdapter.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/BottomSheetAdapter.kt @@ -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 } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyUtil.kt b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyUtil.kt new file mode 100644 index 000000000..236316fef --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyUtil.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java index 21dd14131..3b3b798eb 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java @@ -232,13 +232,23 @@ public class Place implements Parcelable { */ @Nullable public String getWikiDataEntityId() { + if (this.entityID != null && !this.entityID.equals("")) { + return this.entityID; + } + if (!hasWikidataLink()) { Timber.d("Wikidata entity ID is null for place with sitelink %s", siteLinks.toString()); return null; } + //Determine entityID from link String wikiDataLink = siteLinks.getWikidataLink().toString(); - return wikiDataLink.replace("http://www.wikidata.org/entity/", ""); + + if (wikiDataLink.contains("http://www.wikidata.org/entity/")) { + this.entityID = wikiDataLink.substring("http://www.wikidata.org/entity/".length()); + return this.entityID; + } + return null; } /** diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt index 7156568b6..d9a76c25d 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt @@ -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>, ActivityResultLauncher) -> 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() } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java b/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java index e46e95353..1d59fcd34 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java @@ -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); } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java deleted file mode 100644 index f3224de7f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.java +++ /dev/null @@ -1,2397 +0,0 @@ -package fr.free.nrw.commons.nearby.fragments; - -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.CUSTOM_QUERY; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SLIGHTLY_CHANGED; -import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.MAP_UPDATED; -import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT; - -import android.Manifest.permission; -import android.annotation.SuppressLint; -import android.app.ProgressDialog; -import android.content.ActivityNotFoundException; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.graphics.drawable.Drawable; -import android.location.Location; -import android.location.LocationManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MenuItem.OnMenuItemClickListener; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.ViewGroup; -import android.view.ViewGroup.LayoutParams; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.widget.Toast; -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog.Builder; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.lifecycle.LifecycleCoroutineScope; -import androidx.lifecycle.LifecycleOwnerKt; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import com.google.android.material.bottomsheet.BottomSheetBehavior; -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback; -import com.google.android.material.snackbar.Snackbar; -import com.jakewharton.rxbinding2.view.RxView; -import com.jakewharton.rxbinding3.appcompat.RxSearchView; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.CommonsApplication.BaseLogoutListener; -import fr.free.nrw.commons.MapController.NearbyPlacesInfo; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao; -import fr.free.nrw.commons.contributions.ContributionController; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.contributions.MainActivity.ActiveFragment; -import fr.free.nrw.commons.databinding.FragmentNearbyParentBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import fr.free.nrw.commons.location.LatLng; -import fr.free.nrw.commons.location.LocationPermissionsHelper; -import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback; -import fr.free.nrw.commons.location.LocationServiceManager; -import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType; -import fr.free.nrw.commons.location.LocationUpdateListener; -import fr.free.nrw.commons.nearby.BottomSheetAdapter; -import fr.free.nrw.commons.nearby.CheckBoxTriStates; -import fr.free.nrw.commons.nearby.Label; -import fr.free.nrw.commons.nearby.MarkerPlaceGroup; -import fr.free.nrw.commons.nearby.NearbyController; -import fr.free.nrw.commons.nearby.NearbyFilterSearchRecyclerViewAdapter; -import fr.free.nrw.commons.nearby.NearbyFilterState; -import fr.free.nrw.commons.nearby.Place; -import fr.free.nrw.commons.nearby.PlacesRepository; -import fr.free.nrw.commons.nearby.Sitelinks; -import fr.free.nrw.commons.nearby.WikidataFeedback; -import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract; -import fr.free.nrw.commons.nearby.fragments.AdvanceQueryFragment.Callback; -import fr.free.nrw.commons.nearby.model.BottomSheetItem; -import fr.free.nrw.commons.nearby.presenter.NearbyParentFragmentPresenter; -import fr.free.nrw.commons.upload.FileUtils; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.ExecutorUtils; -import fr.free.nrw.commons.utils.LayoutUtils; -import fr.free.nrw.commons.utils.MapUtils; -import fr.free.nrw.commons.utils.NearbyFABUtils; -import fr.free.nrw.commons.utils.NetworkUtils; -import fr.free.nrw.commons.utils.SystemThemeUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.wikidata.WikidataEditListener; -import io.reactivex.Completable; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.TimeUnit; -import javax.inject.Inject; -import javax.inject.Named; -import kotlin.Unit; -import org.jetbrains.annotations.NotNull; -import org.osmdroid.api.IGeoPoint; -import org.osmdroid.events.MapEventsReceiver; -import org.osmdroid.events.MapListener; -import org.osmdroid.events.ScrollEvent; -import org.osmdroid.events.ZoomEvent; -import org.osmdroid.tileprovider.tilesource.TileSourceFactory; -import org.osmdroid.util.GeoPoint; -import org.osmdroid.util.constants.GeoConstants.UnitOfMeasure; -import org.osmdroid.views.CustomZoomButtonsController.Visibility; -import org.osmdroid.views.overlay.MapEventsOverlay; -import org.osmdroid.views.overlay.Marker; -import org.osmdroid.views.overlay.Overlay; -import org.osmdroid.views.overlay.ScaleBarOverlay; -import org.osmdroid.views.overlay.ScaleDiskOverlay; -import org.osmdroid.views.overlay.TilesOverlay; -import timber.log.Timber; - - -public class NearbyParentFragment extends CommonsDaggerSupportFragment - implements NearbyParentFragmentContract.View, - WikidataEditListener.WikidataP18EditListener, LocationUpdateListener, - LocationPermissionCallback, BottomSheetAdapter.ItemClickListener { - - FragmentNearbyParentBinding binding; - - public final MapEventsOverlay mapEventsOverlay = new MapEventsOverlay(new MapEventsReceiver() { - @Override - public boolean singleTapConfirmedHelper(GeoPoint p) { - if (clickedMarker != null) { - clickedMarker.closeInfoWindow(); - } else { - Timber.e("CLICKED MARKER IS NULL"); - } - if (isListBottomSheetExpanded()) { - // Back should first hide the bottom sheet if it is expanded - hideBottomSheet(); - } else if (isDetailsBottomSheetVisible()) { - hideBottomDetailsSheet(); - } - return true; - } - - @Override - public boolean longPressHelper(GeoPoint p) { - return false; - } - }); - - @Inject - LocationServiceManager locationManager; - @Inject - NearbyController nearbyController; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - @Inject - BookmarkLocationsDao bookmarkLocationDao; - @Inject - PlacesRepository placesRepository; - @Inject - ContributionController controller; - @Inject - WikidataEditListener wikidataEditListener; - @Inject - SystemThemeUtils systemThemeUtils; - @Inject - CommonPlaceClickActions commonPlaceClickActions; - - private LocationPermissionsHelper locationPermissionsHelper; - private NearbyFilterSearchRecyclerViewAdapter nearbyFilterSearchRecyclerViewAdapter; - private BottomSheetBehavior bottomSheetListBehavior; - private BottomSheetBehavior bottomSheetDetailsBehavior; - private Animation rotate_backward; - private Animation fab_close; - private Animation fab_open; - private Animation rotate_forward; - private static final float ZOOM_LEVEL = 15f; - private final String NETWORK_INTENT_ACTION = "android.net.conn.CONNECTIVITY_CHANGE"; - private BroadcastReceiver broadcastReceiver; - private boolean isNetworkErrorOccurred; - private Snackbar snackbar; - private View view; - private LifecycleCoroutineScope scope; - private NearbyParentFragmentPresenter presenter; - private boolean isDarkTheme; - private boolean isFABsExpanded; - private Place selectedPlace; - private Marker clickedMarker; - private ProgressDialog progressDialog; - private final double CAMERA_TARGET_SHIFT_FACTOR_PORTRAIT = 0.005; - private final double CAMERA_TARGET_SHIFT_FACTOR_LANDSCAPE = 0.004; - private boolean isPermissionDenied; - private boolean recenterToUserLocation; - private GeoPoint mapCenter; - IntentFilter intentFilter = new IntentFilter(NETWORK_INTENT_ACTION); - private Place lastPlaceToCenter; - private LatLng lastKnownLocation; - private boolean isVisibleToUser; - private LatLng lastFocusLocation; - private PlaceAdapter adapter; - private GeoPoint lastMapFocus; - private NearbyParentFragmentInstanceReadyCallback nearbyParentFragmentInstanceReadyCallback; - private boolean isAdvancedQueryFragmentVisible = false; - private Place nearestPlace; - private volatile boolean stopQuery; - - private final Handler searchHandler = new Handler(); - private Runnable searchRunnable; - - private LatLng updatedLatLng; - private boolean searchable; - - private ConstraintLayout nearbyLegend; - - private GridLayoutManager gridLayoutManager; - private List dataList; - private BottomSheetAdapter bottomSheetAdapter; - - private final ActivityResultLauncher galleryPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks); - }); - }); - - private final ActivityResultLauncher customSelectorLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks); - }); - }); - - private final ActivityResultLauncher cameraPickLauncherForResult = - registerForActivityResult(new StartActivityForResult(), - result -> { - controller.handleActivityResultWithCallback(requireActivity(), callbacks -> { - controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks); - }); - }); - - private ActivityResultLauncher inAppCameraLocationPermissionLauncher = registerForActivityResult( - new RequestMultiplePermissions(), - new ActivityResultCallback>() { - @Override - public void onActivityResult(Map result) { - boolean areAllGranted = true; - for (final boolean b : result.values()) { - areAllGranted = areAllGranted && b; - } - - if (areAllGranted) { - controller.locationPermissionCallback.onLocationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - controller.handleShowRationaleFlowCameraLocation(getActivity(), - inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult); - } else { - controller.locationPermissionCallback.onLocationPermissionDenied( - getActivity().getString( - R.string.in_app_camera_location_permission_denied)); - } - } - } - }); - - private ActivityResultLauncher locationPermissionLauncher = registerForActivityResult( - new RequestPermission(), isGranted -> { - if (isGranted) { - locationPermissionGranted(); - } else { - if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { - DialogUtil.showAlertDialog(getActivity(), - getActivity().getString(R.string.location_permission_title), - getActivity().getString(R.string.location_permission_rationale_nearby), - getActivity().getString(android.R.string.ok), - getActivity().getString(android.R.string.cancel), - () -> { - askForLocationPermission(); - }, - null, - null - ); - } else { - if (isPermissionDenied) { - locationPermissionsHelper.showAppSettingsDialog(getActivity(), - R.string.nearby_needs_location); - } - Timber.d("The user checked 'Don't ask again' or denied the permission twice"); - isPermissionDenied = true; - } - } - }); - - /** - * WLM URL - */ - public static final String WLM_URL = "https://commons.wikimedia.org/wiki/Commons:Mobile_app/Contributing_to_WLM_using_the_app"; - - @NonNull - public static NearbyParentFragment newInstance() { - NearbyParentFragment fragment = new NearbyParentFragment(); - fragment.setRetainInstance(true); - return fragment; - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentNearbyParentBinding.inflate(inflater, container, false); - view = binding.getRoot(); - - initNetworkBroadCastReceiver(); - scope = LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner()); - presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, nearbyController); - progressDialog = new ProgressDialog(getActivity()); - progressDialog.setCancelable(false); - progressDialog.setMessage("Saving in progress..."); - setHasOptionsMenu(true); - - // Inflate the layout for this fragment - return view; - - } - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - inflater.inflate(R.menu.nearby_fragment_menu, menu); - MenuItem refreshButton = menu.findItem(R.id.item_refresh); - MenuItem listMenu = menu.findItem(R.id.list_sheet); - MenuItem saveAsGPXButton = menu.findItem(R.id.list_item_gpx); - MenuItem saveAsKMLButton = menu.findItem(R.id.list_item_kml); - refreshButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - try { - emptyCache(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - listMenu.setOnMenuItemClickListener(new OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - listOptionMenuItemClicked(); - return false; - } - }); - saveAsGPXButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - - @Override - public boolean onMenuItemClick(@NonNull MenuItem item) { - try { - progressDialog.setTitle(getString(R.string.saving_gpx_file)); - progressDialog.show(); - savePlacesAsGPX(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - saveAsKMLButton.setOnMenuItemClickListener(new OnMenuItemClickListener() { - - @Override - public boolean onMenuItemClick(@NonNull MenuItem item) { - try { - progressDialog.setTitle(getString(R.string.saving_kml_file)); - progressDialog.show(); - savePlacesAsKML(); - } catch (Exception e) { - throw new RuntimeException(e); - } - return false; - } - }); - } - - @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - isDarkTheme = systemThemeUtils.isDeviceInNightMode(); - if (Utils.isMonumentsEnabled(new Date())) { - binding.rlContainerWlmMonthMessage.setVisibility(View.VISIBLE); - } else { - binding.rlContainerWlmMonthMessage.setVisibility(View.GONE); - } - locationPermissionsHelper = new LocationPermissionsHelper(getActivity(), locationManager, - this); - - // Set up the floating activity button to toggle the visibility of the legend - binding.fabLegend.setOnClickListener(v -> { - if (binding.nearbyLegendLayout.getRoot().getVisibility() == View.VISIBLE) { - binding.nearbyLegendLayout.getRoot().setVisibility(View.GONE); - } else { - binding.nearbyLegendLayout.getRoot().setVisibility(View.VISIBLE); - } - }); - - presenter.attachView(this); - isPermissionDenied = false; - recenterToUserLocation = false; - initThemePreferences(); - initViews(); - presenter.setActionListeners(applicationKvStore); - org.osmdroid.config.Configuration.getInstance().load(this.getContext(), - PreferenceManager.getDefaultSharedPreferences(this.getContext())); - - // Use the Wikimedia tile server, rather than OpenStreetMap (Mapnik) which has various - // restrictions that we do not satisfy. - binding.map.setTileSource(TileSourceFactory.WIKIMEDIA); - binding.map.setTilesScaledToDpi(true); - - // Add referer HTTP header because the Wikimedia tile server requires it. - // This was suggested by Dmitry Brant within an email thread between us and WMF. - org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put( - "Referer", "http://maps.wikimedia.org/" - ); - - if (applicationKvStore.getString("LastLocation") - != null) { // Checking for last searched location - String[] locationLatLng = applicationKvStore.getString("LastLocation").split(","); - lastMapFocus = new GeoPoint(Double.valueOf(locationLatLng[0]), - Double.valueOf(locationLatLng[1])); - } else { - lastMapFocus = new GeoPoint(51.50550, -0.07520); - } - ScaleBarOverlay scaleBarOverlay = new ScaleBarOverlay(binding.map); - scaleBarOverlay.setScaleBarOffset(15, 25); - Paint barPaint = new Paint(); - barPaint.setARGB(200, 255, 250, 250); - scaleBarOverlay.setBackgroundPaint(barPaint); - scaleBarOverlay.enableScaleBar(); - binding.map.getOverlays().add(scaleBarOverlay); - binding.map.getZoomController().setVisibility(Visibility.NEVER); - binding.map.getController().setZoom(ZOOM_LEVEL); - binding.map.getOverlays().add(mapEventsOverlay); - - binding.map.addMapListener(new MapListener() { - @Override - public boolean onScroll(ScrollEvent event) { - presenter.handleMapScrolled(scope, !isNetworkErrorOccurred); - return true; - } - - @Override - public boolean onZoom(ZoomEvent event) { - return false; - } - - }); - - binding.map.setMultiTouchControls(true); - if (nearbyParentFragmentInstanceReadyCallback != null) { - nearbyParentFragmentInstanceReadyCallback.onReady(); - } - initNearbyFilter(); - addCheckBoxCallback(); - moveCameraToPosition(lastMapFocus); - initRvNearbyList(); - onResume(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY)); - } else { - //noinspection deprecation - binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); - } - binding.tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); - binding.nearbyFilterList.btnAdvancedOptions.setOnClickListener(v -> { - binding.nearbyFilter.searchViewLayout.searchView.clearFocus(); - showHideAdvancedQueryFragment(true); - final AdvanceQueryFragment fragment = new AdvanceQueryFragment(); - final Bundle bundle = new Bundle(); - try { - bundle.putString("query", - FileUtils.INSTANCE.readFromResource( - "/queries/radius_query_for_upload_wizard.rq") - ); - } catch (IOException e) { - Timber.e(e); - } - fragment.setArguments(bundle); - fragment.callback = new Callback() { - @Override - public void close() { - showHideAdvancedQueryFragment(false); - } - - @Override - public void reset() { - presenter.setAdvancedQuery(null); - presenter.updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED); - showHideAdvancedQueryFragment(false); - } - - @Override - public void apply(@NotNull final String query) { - presenter.setAdvancedQuery(query); - presenter.updateMapAndList(CUSTOM_QUERY); - showHideAdvancedQueryFragment(false); - } - }; - getChildFragmentManager().beginTransaction() - .replace(R.id.fl_container_nearby_children, fragment) - .commit(); - }); - - binding.tvLearnMore.setOnClickListener(v -> onLearnMoreClicked()); - - if (!locationPermissionsHelper.checkLocationPermission(getActivity())) { - askForLocationPermission(); - } - } - - /** - * Initialise background based on theme, this should be doe ideally via styles, that would need - * another refactor - */ - private void initThemePreferences() { - if (isDarkTheme) { - binding.bottomSheetNearby.rvNearbyList.setBackgroundColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.map.getOverlayManager().getTilesOverlay() - .setColorFilter(TilesOverlay.INVERT_COLORS); - } else { - binding.bottomSheetNearby.rvNearbyList.setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.checkboxTriStates.setTextColor( - getContext().getResources().getColor(R.color.contributionListDarkBackground)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - binding.nearbyFilterList.getRoot().setBackgroundColor( - getContext().getResources().getColor(android.R.color.white)); - } - } - - private void initRvNearbyList() { - binding.bottomSheetNearby.rvNearbyList.setLayoutManager( - new LinearLayoutManager(getContext())); - adapter = new PlaceAdapter(bookmarkLocationDao, - place -> { - moveCameraToPosition( - new GeoPoint(place.location.getLatitude(), place.location.getLongitude())); - return Unit.INSTANCE; - }, - (place, isBookmarked) -> { - presenter.toggleBookmarkedStatus(place); - return Unit.INSTANCE; - }, - commonPlaceClickActions, - inAppCameraLocationPermissionLauncher, - galleryPickLauncherForResult, - cameraPickLauncherForResult - ); - binding.bottomSheetNearby.rvNearbyList.setAdapter(adapter); - } - - private void addCheckBoxCallback() { - binding.nearbyFilterList.checkboxTriStates.setCallback( - (o, state, b, b1) -> presenter.filterByMarkerType(o, state, b, b1)); - } - - private void performMapReadyActions() { - if (((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) { - if (applicationKvStore.getBoolean("doNotAskForLocationPermission", false) && - !locationPermissionsHelper.checkLocationPermission(getActivity())) { - isPermissionDenied = true; - } - } - presenter.onMapReady(); - } - - @Override - public void askForLocationPermission() { - Timber.d("Asking for location permission"); - locationPermissionLauncher.launch(permission.ACCESS_FINE_LOCATION); - } - - private void locationPermissionGranted() { - isPermissionDenied = false; - applicationKvStore.putBoolean("doNotAskForLocationPermission", false); - lastKnownLocation = locationManager.getLastLocation(); - LatLng target = lastKnownLocation; - if (lastKnownLocation != null) { - GeoPoint targetP = new GeoPoint(target.getLatitude(), target.getLongitude()); - mapCenter = targetP; - binding.map.getController().setCenter(targetP); - recenterMarkerToPosition(targetP); - moveCameraToPosition(targetP); - } else if (locationManager.isGPSProviderEnabled() - || locationManager.isNetworkProviderEnabled()) { - locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER); - locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER); - setProgressBarVisibility(true); - } else { - locationPermissionsHelper.showLocationOffDialog(getActivity(), - R.string.ask_to_turn_location_on_text); - } - presenter.onMapReady(); - registerUnregisterLocationListener(false); - } - - @Override - public void onResume() { - super.onResume(); - binding.map.onResume(); - presenter.attachView(this); - registerNetworkReceiver(); - if (isResumed() && ((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) { - if (locationPermissionsHelper.checkLocationPermission(getActivity())) { - locationPermissionGranted(); - } else { - startMapWithoutPermission(); - } - } - } - - /** - * Starts the map without GPS and without permission By default it points to 51.50550,-0.07520 - * coordinates, other than that it points to the last known location which can be get by the key - * "LastLocation" from applicationKvStore - */ - private void startMapWithoutPermission() { - if (applicationKvStore.getString("LastLocation") != null) { - final String[] locationLatLng - = applicationKvStore.getString("LastLocation").split(","); - lastKnownLocation - = new LatLng(Double.parseDouble(locationLatLng[0]), - Double.parseDouble(locationLatLng[1]), 1f); - } else { - lastKnownLocation = MapUtils.getDefaultLatLng(); - } - if (binding.map != null) { - moveCameraToPosition( - new GeoPoint(lastKnownLocation.getLatitude(), lastKnownLocation.getLongitude())); - } - presenter.onMapReady(); - } - - private void registerNetworkReceiver() { - if (getActivity() != null) { - getActivity().registerReceiver(broadcastReceiver, intentFilter); - } - } - - @Override - public void onPause() { - super.onPause(); - binding.map.onPause(); - compositeDisposable.clear(); - presenter.detachView(); - registerUnregisterLocationListener(true); - try { - if (broadcastReceiver != null && getActivity() != null) { - getContext().unregisterReceiver(broadcastReceiver); - } - - if (locationManager != null && presenter != null) { - locationManager.removeLocationListener(presenter); - locationManager.unregisterLocationManager(); - } - } catch (final Exception e) { - Timber.e(e); - //Broadcast receivers should always be unregistered inside catch, you never know if they were already registered or not - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - searchHandler.removeCallbacks(searchRunnable); - presenter.removeNearbyPreferences(applicationKvStore); - } - - private void initViews() { - Timber.d("init views called"); - initBottomSheets(); - loadAnimations(); - setBottomSheetCallbacks(); - addActionToTitle(); - if (!Utils.isMonumentsEnabled(new Date())) { - NearbyFilterState.setWlmSelected(false); - } - } - - /** - * a) Creates bottom sheet behaviours from bottom sheets, sets initial states and visibility b) - * Gets the touch event on the map to perform following actions: if fab is open then close fab. - * if bottom sheet details are expanded then collapse bottom sheet details. if bottom sheet - * details are collapsed then hide the bottom sheet details. if listBottomSheet is open then - * hide the list bottom sheet. - */ - @SuppressLint("ClickableViewAccessibility") - private void initBottomSheets() { - bottomSheetListBehavior = BottomSheetBehavior.from(binding.bottomSheetNearby.bottomSheet); - bottomSheetDetailsBehavior = BottomSheetBehavior.from(binding.bottomSheetDetails.getRoot()); - bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - binding.bottomSheetDetails.getRoot().setVisibility(View.VISIBLE); - bottomSheetListBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - } - - /** - * Determines the number of spans (columns) in the RecyclerView based on device orientation - * and adapter item count. - * - * @return The number of spans to be used in the RecyclerView. - */ - private int getSpanCount() { - int orientation = getResources().getConfiguration().orientation; - if (bottomSheetAdapter != null) { - return (orientation == Configuration.ORIENTATION_PORTRAIT) ? 3 - : bottomSheetAdapter.getItemCount(); - } else { - return (orientation == Configuration.ORIENTATION_PORTRAIT) ? 3 : 6; - } - } - - public void initNearbyFilter() { - binding.nearbyFilterList.getRoot().setVisibility(View.GONE); - hideBottomSheet(); - binding.nearbyFilter.searchViewLayout.searchView.setOnQueryTextFocusChangeListener( - (v, hasFocus) -> { - LayoutUtils.setLayoutHeightAlignedToWidth(1.25, - binding.nearbyFilterList.getRoot()); - if (hasFocus) { - binding.nearbyFilterList.getRoot().setVisibility(View.VISIBLE); - presenter.searchViewGainedFocus(); - } else { - binding.nearbyFilterList.getRoot().setVisibility(View.GONE); - } - }); - binding.nearbyFilterList.searchListView.setHasFixedSize(true); - binding.nearbyFilterList.searchListView.addItemDecoration( - new DividerItemDecoration(getContext(), - DividerItemDecoration.VERTICAL)); - final LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getActivity()); - linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL); - binding.nearbyFilterList.searchListView.setLayoutManager(linearLayoutManager); - nearbyFilterSearchRecyclerViewAdapter = new NearbyFilterSearchRecyclerViewAdapter( - getContext(), new ArrayList<>(Label.valuesAsList()), - binding.nearbyFilterList.searchListView); - nearbyFilterSearchRecyclerViewAdapter.setCallback( - new NearbyFilterSearchRecyclerViewAdapter.Callback() { - @Override - public void setCheckboxUnknown() { - presenter.setCheckboxUnknown(); - } - - @Override - public void filterByMarkerType(final ArrayList

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

+ + + + \ No newline at end of file diff --git a/app/src/main/res/menu/nearby_fragment_menu.xml b/app/src/main/res/menu/nearby_fragment_menu.xml index fe049cde4..e7c23ed89 100644 --- a/app/src/main/res/menu/nearby_fragment_menu.xml +++ b/app/src/main/res/menu/nearby_fragment_menu.xml @@ -12,6 +12,12 @@ android:icon="@drawable/ic_list_white_24dp" /> + + خطأ في جلب المعالم القريبة.
لا توجد عمليات بحث حديثة هل أنت متأكد من أنك تريد مسح سجل بحثك؟ - هل انت متأكد انك تريد الغاء هذا التحميل + هل أنت متأكد أنك تريد إلغاء هذا التحميل؟ هل تريد حذف هذا البحث؟ تم حذف سجل البحث ترشيح للحذف @@ -882,4 +884,7 @@ الاختفاء هو <b>الملاذ الأخير</b> ويجب <b>استخدامه فقط عندما ترغب في التوقف عن التحرير إلى الأبد</b> وأيضًا لإخفاء أكبر عدد ممكن من ارتباطاتك السابقة.<br/><br/> يتم حذف الحساب على ويكيميديا كومنز عن طريق تغيير اسم حسابك بحيث لا يتمكن الآخرون من التعرف على مساهماتك في عملية تسمى اختفاء الحساب. <b>لا يضمن الاختفاء عدم الكشف عن الهوية تمامًا أو إزالة المساهمات في المشاريع</b> . الشرح تم نسخ التسمية التوضيحية إلى الحافظة + مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل. + عرض في استكشاف + عرض في المناطق القريبة diff --git a/app/src/main/res/values-ast/strings.xml b/app/src/main/res/values-ast/strings.xml index 9b833119c..8d0dba79a 100644 --- a/app/src/main/res/values-ast/strings.xml +++ b/app/src/main/res/values-ast/strings.xml @@ -145,7 +145,7 @@ Inda nun xubió denguna foto. Reintentar Zarrar - El presentar esta imaxe, declaro que ye una obra propia, que nun contien material con derechu d\'autor o «selfies», y que s\'atien a les <a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\">polítiques de Wikimedia Commons</a>. + El presentar esta imaxe, declaro que ye una obra propia, que nun contién material con derechu d\'autor o «selfies», y que s\'atien a les <a href=\"https://commons.wikimedia.org/wiki/Commons:Policies_and_guidelines\">polítiques de Wikimedia Commons</a>. Descargar Llicencia predeterminada Usar un títulu y descripción anterior diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index cbe23481c..1662c2301 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -10,6 +10,7 @@ * Toghrul Rahimli * Wertuose * Şeyx Şamil +* Əkrəm * Əkrəm Cəfər --> diff --git a/app/src/main/res/values-ce/strings.xml b/app/src/main/res/values-ce/strings.xml index 395bfbca3..a009bd39d 100644 --- a/app/src/main/res/values-ce/strings.xml +++ b/app/src/main/res/values-ce/strings.xml @@ -68,10 +68,10 @@ Декъашхочун цӀе Пароль Commons Beta тӀехь хьай цӀарца чугӀо - ЧугӀо + Чувала Йицйелла пароль? ДӀайаздалар - Системин чудахар + Системи чу валар Дехар до, собарде… титраш ​​а, йийцарш а карладохуш ду.. Дехар до, собарде… diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4b17a0fec..232f56c49 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -820,4 +820,7 @@ Forsvinding er en <b>sidste udvej</b> og bør <b>kun bruges, når du for altid ønsker at stoppe med at redigere</b> og også for at skjule så mange af dine tidligere tilknytninger som muligt.<br/><br/> Kontosletning på Wikipedia Commons sker ved at ændre dit kontonavn, således at andre ikke kan genkende dine bidrag i en proces, der kaldes kontoforsvinding (Vanishing). <b>Forsvinding garanterer ikke fuldstændig anonymitet eller fjerner bidrag til projekterne</b> . Billedtekst Billedtekst kopieret til udklipsholder + Tillykke, alle billeder i dette album er enten blevet uploadet eller markeret som ikke til upload. + Vis i Udforsk + Vis i I nærheden diff --git a/app/src/main/res/values-diq/strings.xml b/app/src/main/res/values-diq/strings.xml index 836e8ef62..5ffda9f2e 100644 --- a/app/src/main/res/values-diq/strings.xml +++ b/app/src/main/res/values-diq/strings.xml @@ -3,6 +3,7 @@ * 1917 Ekim Devrimi * Envlh * Gambollar +* GolyatGeri * Gorizon * Gırd * Marmase @@ -199,7 +200,7 @@ Çım berze cı İzahat nêvineya Pela dosyay commonsi - Pbcey Wikidata + Pbcey Wikidayıt Meqaley Wikipedia Muhtemel problemê nê resımi Resım zehf tariyo. @@ -216,7 +217,7 @@ Ravêre Cı kewe Telimati - Wikidata + Wikidayıt Wikipediya Commons Rey bıdê @@ -246,7 +247,7 @@ Weçinaye Raya mobiliya biyo bar Xerita - Resım , Wikidata dê biyo obcey %1$s miyan! + Resım , Wikidayıt dê biyo obcey %1$s miyan! Wallpaper eyar kerê Wallpaper eyar biyo! Quiz @@ -349,4 +350,6 @@ Tayêna bıwane Şınasnayış Raçarne + Hesab + Bınnuşte diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 07021189f..5aeda5299 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -855,4 +855,6 @@ Utilisations du fichier Légende Légende copiée dans le presse-papier + Afficher dans Explorer + Afficher à proximité diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 152edac1c..91f9652c4 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -6,6 +6,7 @@ * Anandra * AnupamM * Bhatakati aatma +* Bunnypranav * Gopalindians * Nilesh shukla * Nitin1485 @@ -28,33 +29,47 @@ कॉमन्स गिटहब सोर्स कोड कॉमन्स का प्रतीक चिन्ह कॉमन्स का जालस्थान + निकास स्थान चयनकर्ता जमा करें + एक और विवरण जोड़ें + नया योगदान जोड़ें + कैमरे से योगदान जोड़ें + फ़ोटो से योगदान जोड़ें + पिछले योगदान गैलरी से योगदान जोड़ें + कैप्शन + भाषा विवरण कैप्शन विवरण चित्र सभी + ऊपर टॉगल करें + स्थान राज्य आज का चित्र %1$d फ़ाइल अपलोड हो रही %1$d फ़ाइलें अपलोड हो रहीं - - \@string/contributions_subtitle_zero + (%1$d) (%1$d) - - %1$d अपलोड शुरू - %1$d अपलोड शुरू + अपलोड शुरू + + %d अपलोड संसाधित + %d अपलोड संसाधित - - %1$d अपलोड - %1$d अपलोड + + %d अपलोड + %d अपलोड इस चित्र का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा इन चित्रों का प्रयोग %1$s लाइसेंस के अन्तर्गत होगा + + %1$d अपलोड + %1$d अपलोड + खोजें स्वरूप सामान्य diff --git a/app/src/main/res/values-io/strings.xml b/app/src/main/res/values-io/strings.xml index 2efec0607..057e77e10 100644 --- a/app/src/main/res/values-io/strings.xml +++ b/app/src/main/res/values-io/strings.xml @@ -79,7 +79,7 @@ Vu atingis la maxim granda quanto di probi por sendar arkivo permisata! Voluntez interuptar la kargajo, e probez itere! Ka desmuntar l\'optimizo di la baterio? Sendar plu kam 3 imaji esas plu efikiva kande l\'optimizo di la baterio esas desmuntita. Voluntez desmuntar l\'optimizo di la baterio del ajusti dil utensilo de Commons, por plugrandigar l\'efikeso. \n\nQuale desmuntar l\'optimizo-sistemo di la baterio:\n\n#: Kliktez la butono \"ajusti\" (\'\'Settings\'\') adinfre.\n\n#: Selektez \"ne optimizita (\'\'Not optimized\'\') por omna utensili (\'\'All apps\'\').\n\n#: Serchez \"Commons\" o \"fr.free.nrw.commons\".\n\n#: Kliktez ol e selektez \"ne optimizar\" (\'\'Don\'t optimize\'\').\n\n#: Kliktez \"facita\" (\'\'Done\'\'). - L\'autentikigo faliis, voluntez itere enirar. + Autentikigo faliis. Voluntez itere enirar. Komencis sendar! Sendajo ajornata (modulo \"limitizita konekto\" aktiva) %1$s sendesis! @@ -100,17 +100,19 @@ Fotografar Proxime Mea sendaji + Kopiez ligilo + La ligilo kopiesis a \'\'clipboard\'\'. Partigar Vidar arkivo-pagino Titulo (Bezonata) Voluntez informar deskripto-texto por ca arkivo Deskripto Deskripto-texto - Ne esis posibla facar \'\'log - in\'\' - la reto faliis + Ne esas posibla enirar - la reto faliis Multa sensucesa probi pri konektar. Voluntez probar itere pos kelka minuti. Pardonez, ca uzero blokusesis che Commons Vu mustas uzar vua autentikigo en du etapi. - Eniro faliis + Eniro faliis Kargar Nomizes ca ajusto Modifikuri @@ -118,6 +120,7 @@ Serchar kategorii Serchez kozi quin vua \'\'media\'\' montras (monti, \'\'Taj Mahal\'\', edc.) Registragar + Menuo pri exterfluajo Rinovigar Listar (Nula arkivo sendita til nun) @@ -258,6 +261,7 @@ Konservar en vua enmagaziniguro la fotografuri obtenita uzanta fotografilo del utensilo (\'\'app\'\') Enirez en vua konto Sendez arkivo \'\'log\'\' + Sendez protokolo per e-posto a developeri, por helpar la solvo di problemi dil \'\'app\'\'. Atencez: protokoli povas kontenar informi por identifiko Nula retnavigilo trovita, por apertar la URL Eroro! URL ne trovita Propozar efaco @@ -266,11 +270,12 @@ Saltar Enirar Ka vu deziras ne enirar? - Vu mustus facar \'\'log in\'\' por sendor imaji future. + Future, vu mustus facar \'\'log in\'\' por sendar imaji. Voluntez enirar por uzar ca utensilo Kopiez Wiki-texto a \'clipboard\' Wiki-texto kopiesis a \'clipboard\' Proximeso povas ne funcionar korekte, nam Lokizo ne esas disponebla. + Interreto nedisponebla. Montranta nur elementi enmagazinigita lokale. Aceso a lokizo ne permisita. Voluntez informar manuale vua lokizo por uzar ca resurso*. Permiso bezonata por montrar listo pri vicina loki Permiso bezonata por montrar listo pri vicina imaji @@ -354,18 +359,22 @@ Efacar Sucesi Profilo + Insigni Statistiko Danki recevita Remarkinda imaji Imaji tra \"Loki Vicina\" - Nivelo + Nivelo %d + %s (Nivelo %s) Imaji sendita Imaji ne reversionita Imaji uzita Partigez vua sucesi kun vua amiki! + Vua nivelo augmentas kande vu atingas bezonata postuli. Elementi en la segmento \"statistiko\" ne augmentas vua nivelo. minima quanto bezonata: Quanto di imaji quin vu sendis a Commons, uzanta irga softwaro* por sendar li La procento di imaji quin vu sendis a Commons, qui ne efacesis pose + La quanto di imaji sendita da vu a Commons, qui uzesis en artikli de Wikimedia. Eventis eroro! Avizo de Commons Uzar personalizita autoro-nomo @@ -375,6 +384,7 @@ Vicina Avizi Avizi (lektita) + Montrez proxima avizo Listo Permiso pri enmagazinigo Etapo %1$d de %2$d: %3$s @@ -383,6 +393,8 @@ Arkivo kun la nomo %1$s ja existas. Ka vu deziras durigar?\n\nNoto: Sufixo adequata adjuntesos automatale a la nomo dil imajo. Imaji Loki + Kategorii + Adjuntez/Removez marko-rubandi (\'\'bookmark\'\'-i) Marko-rubandi Vu ne adjuntis marko-rubandi Marko-rubandi @@ -392,7 +404,11 @@ Me konstatis ke ol esas mala por mea privateso Me chanjis mea ideo: me ne pluse deziras ke ol esos publike videbla Pardonez! Ca imajo ne esas interesanta por ula enciklopedio + Adjuntita da me, che %1$s, uzita en %2$d artiklo/artikli. + Bonveno a Commons!\n\nSendez vua unesma arkivo kliktanta sur butono \"adjuntez\" (\'\'add\'\'). Nula kategorio selektita + Imaji sen kategorii rare esas uzebla. Ka vu fakte deziras sendar ol sen selektar irga kategorio? + Nula deskripturo selektita Cesar kargajo Durar kargajo Serchez ca areo @@ -401,15 +417,38 @@ Ne pluse demandez to Demandar lokala permiso Demandez lokala permiso, kande bezonata por uzar karto montranta proximeso. + Finas la: + Montrez kampanii + Videz la kampanii duranta Permisar Eskartar + Facita + Sendanta danko: Suceso + Danko sendita sucese a %1$s + Faliis pri sendar danko a %1$s + Sendanta danko: Falio + Sendanta danko a %1$s + Ka to obedias la reguli pri autoroyuro? + Ka lua kategorio esas korekta? + Ka vu deziras dankar la kontributero? + Kliktez NO por indikar ca imajo por efaco, se ol ne havas irga utileso. + Ho, to ne mem havas kategorio! + Ca imajo havas %1$s kategorii. + Ol esas kontre la skopo, nam ol esas + To esas violaco di autoroyuro, nam ol esas Sequanta imajo Yes, pro quo ne? + Kliktanta ca butono donos a vu altra imajo recente sendita a Wikimedia Commons Vu povas revizar imaji, por plubonigar la qualeso di Wikimedia Commons.\nLa tri revizo-parametri esas:\n\n- Kad ica imajo havas havas irga relato kun la kuntexto?\nKande tu kliktas NO, vu adjuntos indiko (shablono) por ke ol efacesos.\n\n- Kad ica imajo violacas autoroyuro?\nSe tu klitos YES, vu adjuntos indiko por ke ol efacesos.\n\n- Kad la kategorii di ica imajo esas korekta?\nSe tu kliktos NO, vu adjuntos demando pri adjuntar korekta kategorio ad ol.\n\nSe omno esas korekta, nula shablono adjuntesos al imajo, e vu povos dankar la persono qua sendis ol. + Nula imajo uzita + Nula imajo desfacita + Nula imajo sendita Vu havas nul avizi sen lektar Vu ne lektis irga avizo + Verifikez vua e-postal adreso Vidar lektita Vidar ne-lektata + Eventis eroro dum selekto di imaji Vartez... Kopiita Exempli pri bona imaji por sendar a Commons @@ -431,7 +470,9 @@ Ne povis demandar efaco. komplete neklara Fotografuro de komunikilaro + Hazarda imajo de Interreto Emblemo + Brecho di Libereso di Panoramo Pro ke ol esas Probanta aktualigar kategorii. Aktualigo di kategorio @@ -442,7 +483,14 @@ Ne povis adjuntar kategorii. Aktualigar kategorii + Probanta aktualigar deskripturi. Redaktar deskripturi + + Deskripturo %1$s adjuntesis. + Deskripturi %1$s adjuntesis. + + Ne povis adjuntar deskripturi. + Probanta aktualigar koordinati. Aktualigo di koordinati Aktualigo di deskripturo Aktualigo di surskriburo @@ -451,8 +499,14 @@ Adjuntesis deskripturi. Surskriburo adjuntesis. Ne povis adjuntar koordinati. + Ne povis adjuntar deskripturi. + Ne povis adjuntar deskripturo. + Koordinati dil imajo ne aktualigesis + Ne povis obtenar deskripturi. + Redaktar deskripturi ed informo-texti Partigar imajo uzanta Vu ankore ne facis kontributaji + %s ankore ne facis irga kontributado Konto kreesis! Texto kopiita a \'\'clipboard\'\' Mesajo indikita kom \'lektita\' @@ -462,17 +516,21 @@ Bezonas fotografuro Tipo di lokizo: Ponto, muzeo, hotelo, edc. - Irgu ne funcionis dum \'\'log in\'\'. Vu mustos ridefinar vua pasovorto!! + Irgu faliis dum \'\'log in\'\'. Vu mustos ridefinar vua pasovorto!! \'\'MEDIA\'\' SUBKLASI KLASI PLU ABSTRAKTA Loko proxima trovesis - Ka to esas fotografuro pri %1$s? + Ka ca imaji apartenas a %1$s? + Ka to esas imajo di %1$s? Marko-rubandi Ajusti + Efacita de la marko-rubandi Adjuntita marko-rubandi + Irgu faliis. Ne povis vidar la muropapero Uzar kom skreno-kovrilo Kreanta skreno-kovrilo. Voluntez vartar... + Sequar sistemo Koloro obskura Koloro klara Charjez pluse @@ -492,6 +550,9 @@ Uzero Quanteso Uzar kom \'\'avatar\'\' di la tabelo pri precipua kunlaboranti + Ajusto di avataro + Eroro dum ajusto di nov avataro, voluntez probar itere + Uzar kom avataro Yare Semanale Sempre @@ -502,9 +563,16 @@ Imaji di qualeso Nuliganta sendajo... Cesar kargajo + Montras + Licencizo di \'\'media\'\' + Detali pri \'\'media\'\' + Vidar kategorio-pagino + Vidar pagino dil arkivo Lektez pluse En omna idiomi Selektez lokizo + Selektar lokizo + Montrar en l\'utensilo \'\'app\'\' di mapo Aktualigar lokizo Lokizo dil imajo Verifikez se la lokizo esas korekta @@ -524,8 +592,33 @@ Montrez monumenti SAVEZ PLUSE Bezonas permiso - Vidar uzeropagino + Kontributadi dil uzero: %s + Sucesi dil uzero: %s + Vidar profilo dil uzero + Redaktar deskripturi + Redaktar kategorii + Progresiva selektaji (advanced options) + Aplikar + Restaurar + Nula lokizo trovita Ka vu deziras informar la loko de ube vu obtenis ca imajo?\nInformo pri la lokizo helpos editeri trovar vua imajo, do ol divenos plu utila.\nDanko! + Adjuntez lokizo + Detali + nivelo di API + versiono di Android + Fabrikanto dil aparato + Modelo dil aparato + Nomo dil aparato + Tipo di reto + Danko por sendar vua opiniono + Eroro dum sendo di respondo + Qual es vua opiniono (feedback)? + Vua opiniono (\'\'feedback\'\') + Indikez por ne sendar ol + Itere indikez por sendar ol + Indikanta ke ol ne sendesos + Ca imajo ja sendesis + Ne povis selektar ca imajo por sendar (\'\'upload\'\') Imajo selektita Ca imajo indikesis por ne sendesar Raporto @@ -535,13 +628,49 @@ Avizar ca uzero Informar ca kontenajo Demandar blokuso di ca uzero + Uzez du fingri por augmentar o diminutar \'\'zoom\'\'. Koordinati ne esas l\'exakta, tamen l\'individuo qua sendis ca imajo kredas ke la koordinati quin lu informis esas suficante proxima. Modifikar imajo Aktualigar lokizo + Lokizo aktualigita! + Removar lokizo + Removar avizo pri lokizo + Lokizo efacita! Dankar l\'autoro Eroro sendanta danki al autoro. + La tempo-quanto por vua \'\'log in\'\' finis. Voluntez itere enirar. + Konservo sucesoza di arkivo + Ka vu deziras apertar arkivo GPX? + Ka vu deziras apartar l\'arkivo KML? + Faliis pri konservar arkivo KML. + Faliis pri konservar arkivo GPX. + Konservanta arqkivo KML + Konservanta arkivo GPX %d imajo selektita %d imaji selektita + Diskuto + Dicez irgu pri l\'arkivo \'%1$s\'. Ol esos videbla publike. + Arkivi sendita + Vartanta + Faliis + Ne povis inkluzar datumi pri la loko + Efacar faldilo + Konfirmez efaco + Ka vu deziras efacar faldilo %1$s, kontenanta %2$d arkivi? + Efacez + Nuligez + Faldilo %1$s sucese efacita + Faliis pri efacar faldilo %1$s + Commons + Altra wiki + Uzi dil arkivo + SingleWebViewActivity + Konto + Efacar konto + Avizo pri efaco di konto + Deskripto-texto + Deskripto-texto kopiita a \'\'clipboard\'\' + Gratuli! Omna imaji en ca albumo sive sendesis, sive indikesis por ne sendar. diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index fd3bbb163..7203f3e1f 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,5 +1,6 @@ + കോമൺസ് ഫേസ്ബുക്ക് പേജ് + കോമൺസ് ജിത്ഹബ് സോഴ്സ് കോഡ് കോമൺസ് ലോഗോ കോമൺസ് വെബ്‌സൈറ്റ് + സമർപ്പിക്കുക + മറ്റൊരു വിവരണം ചേർക്കുക + പുതിയ സംഭാവന ചേർക്കുക + ക്യാമറയിൽ നിന്നുള്ള സംഭാവന ചേർക്കുക + ഫോട്ടോകളിൽ നിന്നുള്ള സംഭാവന ചേർക്കുക + മുമ്പത്തെ സംഭാവനകളുടെ ഗാലറിയിൽ നിന്നുള്ള സംഭാവന ചേർക്കുക + തലവാചകം + ഭാഷാ വിവരണം + തലവാചകം + വിവരണം + ചിത്രം + എല്ലാം + ടോഗിൾ അപ്പ് + തിരയൽ കാഴ്ച + ദിവസത്തെ ചിത്രം ഒരു പ്രമാണം അപ്‌ലോഡ് ചെയ്യുന്നു %1$d പ്രമാണങ്ങൾ അപ്‌ലോഡ് ചെയ്യുന്നു @@ -19,6 +37,7 @@ ഒരു അപ്‌ലോഡ് %1$d അപ്‌ലോഡുകൾ + അപ്‌ലോഡുകൾ ആരംഭിക്കുന്നു ഒരു അപ്‌ലോഡ് തുടങ്ങുന്നു %1$d അപ്‌ലോഡുകൾ തുടങ്ങുന്നു @@ -35,6 +54,8 @@ സ്വകാര്യത കോമൺസ് സജ്ജീകരണങ്ങൾ + കോമൺസിലേക്ക് അപ്‌ലോഡ് ചെയ്യുക + അപ്‌ലോഡ് പുരോഗമിക്കുന്നു ഉപയോക്തൃനാമം രഹസ്യവാക്ക് താങ്കളുടെ കോമൺസ് ബീറ്റ അംഗത്വത്തിൽ പ്രവേശിക്കുക @@ -43,9 +64,13 @@ അംഗത്വമെടുക്കുക പ്രവേശിക്കുന്നു ദയവായി കാത്തിരിക്കുക… - പ്രവേശനം വിജയകരം! - പ്രവേശനം പരാജയപ്പെട്ടു! + അടിക്കുറിപ്പുകളും വിവരണങ്ങളും അപ്ഡേറ്റ് ചെയ്യുന്നു + ദയവായി കാത്തിരിക്കുക… + പ്രവേശനം വിജയകരം! + പ്രവേശനം പരാജയപ്പെട്ടു! പ്രമാണം കണ്ടെത്താനായില്ല. ദയവായി മറ്റൊരു പ്രമാണം നോക്കുക. + പരമാവധി വീണ്ടും ശ്രമിക്കാനുള്ള പരിധി എത്തി! അപ്‌ലോഡ് റദ്ദാക്കി വീണ്ടും ശ്രമിക്കുക + ബാറ്ററി ഒപ്റ്റിമൈസേഷൻ ഓഫാക്കണോ? സാധുതാനിർണ്ണയം പരാജയപ്പെട്ടു, ദയവായി വീണ്ടും പ്രവേശിക്കുക അപ്‌ലോഡ് തുടങ്ങി! %1$s അപ്‌ലോഡ് ചെയ്തിരിക്കുന്നു! @@ -65,9 +90,14 @@ ചിത്രം എടുക്കുക സമീപസ്ഥം എന്റെ അപ്‌ലോഡുകൾ + ലിങ്ക് പകർത്തുക + ലിങ്ക് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി പങ്ക് വെയ്ക്കുക + പ്രമാണ താൾ കാണുക അടിക്കുറിപ്പ് (നിർബന്ധം) + ദയവായി ഈ ഫയലിന് ഒരു അടിക്കുറിപ്പ് നൽകുക വിവരണം + തലവാചകം പ്രവേശിക്കാനായില്ല - നെറ്റ്‌വർക്ക് പരാജയപ്പെട്ടു നിരവധി വിജയകരമല്ലാത്ത ശ്രമങ്ങൾ നടന്നിരിക്കുന്നു. വീണ്ടും ശ്രമിക്കുന്നതിനു മുമ്പ് ഏതാനം മിനിറ്റുകൾ വിശ്രമിക്കുക. ക്ഷമിക്കുക, ഈ ഉപയോക്താവ് കോമൺസിൽ നിന്ന് തടയപ്പെട്ടിരിക്കുകയാണ് @@ -103,6 +133,7 @@ റദ്ദാക്കുക ഡൗൺലോഡ് സ്വതേയുള്ള ഉപയോഗാനുമതി + വിഷയം ആട്രിബ്യൂഷൻ-ഷെയർ‌എലൈക് 4.0 ആട്രിബ്യൂഷൻ 4.0 ആട്രിബ്യൂഷൻ-ഷെയർ‌എലൈക് 3.0 @@ -122,6 +153,7 @@ വർഗ്ഗങ്ങൾ ശേഖരിക്കുന്നു… ഒന്നും തിരഞ്ഞെടുത്തിട്ടില്ല + അടിക്കുറിപ്പില്ല വിവരണമൊന്നുമില്ല സംവാദങ്ങളില്ല അജ്ഞാതമായ അനുമതി diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ae075e71a..11485b473 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -13,6 +13,7 @@ * Matma Rex * Mazab IZW * Olaf +* PanWor * Rail * Railfail536 * Rainbow P @@ -137,6 +138,8 @@ Zrób zdjęcie W pobliżu Wysłane przeze mnie pliki + Skopiuj link + Link został skopiowany do schowka Udostępnij Pokaż stronę pliku Podpis (wymagany) @@ -155,6 +158,7 @@ Szukaj kategorii Wyszukiwanie elementów, które przedstawiają twoje media (góra, Tadż Mahal itp.) Zapisz + Rozszerzone menu Odśwież Lista (Nie ma jeszcze przesłanych plików) @@ -311,6 +315,7 @@ Skopiuj wikitext do schowka Wikitext został skopiowany do schowka W pobliżu może nie działać poprawnie, Lokalizacja jest niedostępna. + Brak połączenia z internetem. Wyświetlane są tylko miejsca z pamięci podręcznej. Odmowa dostępu do lokalizacji. Aby skorzystać z tej funkcji, ustaw swoją lokalizację ręcznie. Uprawnienie wymagane do wyświetlania listy pobliskich miejsc Uprawnienie wymagane do wyświetlania listy pobliskich zdjęć @@ -394,11 +399,13 @@ Usuń Osiągnięcia Profil + Odznaki Statystyki Otrzymane Dzięki Wyróżnione ilustracje Obrazy za pośrednictwem \"Pobliskie miejsca\" - Poziom + Poziom %d + %s (Poziom %s) Przesłane obrazy Nie wycofane obrazy Wykorzystane obrazy @@ -465,6 +472,8 @@ Zezwól aplikacji na pobieranie lokalizacji, jeśli kamera jej nie rejestruje. Niektóre kamery urządzeń nie rejestrują lokalizacji. W takich przypadkach pozwolenie aplikacji na pobieranie i dołączanie do niej lokalizacji sprawia, że Twój wkład jest bardziej użyteczny. Możesz to zmienić w dowolnym momencie w Ustawieniach Zezwól Odrzuć + Zezwól na dostęp do lokalizacji w Ustawieniach, a następnie spróbuj ponownie.\n\nUwaga: Jeśli aplikacja nie będzie w stanie uzyskać danych o lokalizacji z urządzenia w krótkim czasie, przesłany plik może nie zawierać tych informacji. + Aparat w aplikacji potrzebuje uprawnień na dostęp do lokalizacji, aby dołączyć ją do zdjęć, jeśli nie jest dostępna w danych EXIF. Zezwól aplikacji na dostęp do lokalizacji, a następnie spróbuj ponownie.\n\nUwaga: Jeśli aplikacja nie będzie w stanie uzyskać danych o lokalizacji z urządzenia w krótkim czasie, przesłany plik może nie zawierać tych informacji. Upewnij się, że ten nowy selektor Androida nie usuwa lokalizacji ze zdjęć. Kampanie już nie będą widoczne. Jednak w razie potrzeby możesz ponownie włączyć to powiadomienie w ustawieniach. Ta funkcja wymaga połączenia sieciowego, sprawdź ustawienia połączenia. @@ -781,4 +790,6 @@ Chcesz otworzyć plik KML? Nie udało się zapisać pliku KML. Nie udało się zapisać pliku GPX. + Dyskusja + Usuń diff --git a/app/src/main/res/values-pms/strings.xml b/app/src/main/res/values-pms/strings.xml index 8c388d9ae..76a0be425 100644 --- a/app/src/main/res/values-pms/strings.xml +++ b/app/src/main/res/values-pms/strings.xml @@ -813,4 +813,7 @@ L\'eliminassion a l\'é <b>l\'ùltima arsorsa</b> e a dovrìa <b>esse dovrà mach si chiel a veul chité ëd modifiché për sempe</b> e ëdcò s\'a veul ëstërmé pi che possìbil soe assossiassion passà.<br/><br/>La dëscancelassion ëd cont su Wikimedia a l\'é fàita an modificand sò stranòm an manera che j\'àutri a peulo pa arconòsse soe contribussion ant un process ciamà dëscancelassion ëd cont. <b>La sparission a garantiss pa l\'anonimà complet ni a gava le contribussion dai proget</b>. Legenda Legenda copià an sla taulëtta + Congratulassion, tute le fòto ëd s\'àlbom a son ëstàita carià opura marcà coma da nen carié. + Smon-e andrinta a Explore + Smon-e andrinta a Nearby diff --git a/app/src/main/res/values-ps/strings.xml b/app/src/main/res/values-ps/strings.xml index 4f17da26f..0fc6a3dc0 100644 --- a/app/src/main/res/values-ps/strings.xml +++ b/app/src/main/res/values-ps/strings.xml @@ -6,24 +6,72 @@ * Baloch Khan * Ibrahim khashrowdi * Saraiki +* شاه زمان پټان --> - - 1 پورته کول - %1$d پورته کول + خونديځ ګيټهوب سرچينه کوډ + خونديځ نښان + خونديځ وېبپاڼه + له ځای ټاکونکي وتل + سپارل + بل سپيناوی ورزياتول + نوې ونډې ورزياتول + د کامرې له لارې ونډه ورزياتول + انځورونو له لارې ونډه ورزياتول + د پخوانيو ونډو له انځورتونه د ونډې ورزياتول + نيونګې + ژبې سپيناوی + نيونګ + سپيناوی + انځور + ټول + پورته کول + لټون ليد + ځای حالت + ورځې انځور + + %1$d دوتنه پورته کول + %1$d دوتنې پورته کول + + + (%1$d) + (%1$d) + + پورته کولو پيل + + جريان %d پورته کول + پورته کولو %d جريان + + + %d upload + %d پورته کول دا انځور به د %1$s په منښتليک سمبال وي. + سپړنه + ښکارېدنه + ټولګړی + غبرګون + پټنتيا ويکي خونديځ امستنې - کارن-نوم + خونديځ ته راپورته کول + راپورته کول جريان لري + کارن‌نوم پټنوم + خپل خونديځ بېټا ګڼون ته ورننوځئ ننوتل + پټنوم مو هېر شوی؟ نومليکنه په ننوتلو کې دی لطفاً تم شۍ … - غونډال کې بريالی ورننوتلۍ! - غونډال کې ننوتنه نابريالې شوه! + نيونګې او سپيناوي تازه کول + په تمه اوسئ + بريالی ننوتون + ناسم ننوتون دوتنه و نه موندل شوه. لطفاً د يوې بلې دوتنې د موندلو هڅه وکړئ. + د بياځلي هڅې وروستۍ اندازه پوره شوه! مهرباني وکړئ، لغوه يې کړئ او بيا د راپورته کولو هڅه وکړئ + بيټري سمون بندول؟ + کله چې د بیټرۍ اصلاح بنده وي، له ۳ څخه زیاتو عکسونو اپلوډ کول ډیر باوري کار کوي. مهرباني وکړئ د اسانه اپلوډ تجربې لپاره د کامنز ایپ لپاره د ترتیباتو څخه د بیټرۍ اصلاح بند کړئ. \n\n د بیټرۍ اصلاح بندولو لپاره ممکنه ګامونه:\n\n لومړی ګام: لاندې \'ترتیبات\' تڼۍ باندې کلیک وکړئ.\n\n دوهم ګام: له \'نه غوره شوی\' څخه \'ټول ایپس\' ته واړوئ.\n\n دریم ګام: د \"کامن\" یا \"fr.free.nrw.commons\" لټون وکړئ.\n\n څلورم ګام: دا کلیک کړئ او \'غوره نه کړئ\' غوره کړئ.\n\n پنځم ګام: \'بشپړ شوی\' فشار ورکړئ. پورته کېدنه پيل شوه! %1$s پورته شوی! د %1$s پورته کول @@ -88,4 +136,5 @@ امستنې غبرگون وتل + گڼون diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index 1be987f42..9417f90ec 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -1,5 +1,6 @@