From 8fc7e1039be5d271340ea4fa6d7351af18d8dab8 Mon Sep 17 00:00:00 2001 From: Paul Hawke Date: Fri, 11 Jul 2025 21:11:20 -0500 Subject: [PATCH 1/5] Convert media package to kotlin (#6369) * Convert Caption to kotlin * Convert CaptionListViewAdapter to kotlin * Convert CaptionListViewAdapter to kotlin * Removed unused class * Converted MwParseResult / MwParseResponse to kotlin * Convert CustomOkHttpNetworkFetcher to kotlin * Break up MediaDetailPagerFragment to make it easier to convert to kotlin * Convert MediaDetailProvider to kotlin * Convert the MediaDetailAdapter to kotlin * Convert MediaDetailPagerFragment to kotlin --- .../bookmarks/BookmarkListRootFragment.java | 3 +- .../category/CategoryDetailsActivity.kt | 4 +- .../contributions/ContributionsFragment.kt | 3 +- .../explore/ExploreListRootFragment.java | 4 +- .../explore/ExploreMapRootFragment.java | 4 +- .../nrw/commons/explore/SearchActivity.java | 9 +- .../WikidataItemDetailsActivity.java | 8 +- .../explore/media/PageableMediaFragment.kt | 2 +- .../fr/free/nrw/commons/media/Caption.java | 43 -- .../java/fr/free/nrw/commons/media/Caption.kt | 19 + .../commons/media/CaptionListViewAdapter.java | 67 -- .../commons/media/CaptionListViewAdapter.kt | 34 + .../commons/media/CommonsWikibaseItem.java | 76 -- .../media/CustomOkHttpNetworkFetcher.java | 236 ------ .../media/CustomOkHttpNetworkFetcher.kt | 199 +++++ .../nrw/commons/media/MediaDetailAdapter.kt | 76 ++ .../nrw/commons/media/MediaDetailFragment.kt | 2 - .../media/MediaDetailPagerFragment.java | 678 ------------------ .../commons/media/MediaDetailPagerFragment.kt | 622 ++++++++++++++++ .../nrw/commons/media/MediaDetailProvider.kt | 14 + .../nrw/commons/media/MwParseResponse.java | 25 - .../free/nrw/commons/media/MwParseResponse.kt | 17 + .../free/nrw/commons/media/MwParseResult.java | 18 - .../free/nrw/commons/media/MwParseResult.kt | 18 + .../nearby/fragments/NearbyParentFragment.kt | 3 +- .../BookmarkListRootFragmentUnitTest.kt | 4 +- .../CategoryDetailsActivityUnitTests.kt | 2 +- .../ContributionsFragmentUnitTests.kt | 2 +- .../ExploreListRootFragmentUnitTest.kt | 6 +- .../WikidataItemDetailsActivityUnitTests.kt | 2 +- .../explore/search/SearchActivityUnitTests.kt | 4 +- .../CustomOkHttpNetworkFetcherUnitTest.kt | 8 +- .../media/MediaDetailFragmentUnitTests.kt | 2 +- 33 files changed, 1030 insertions(+), 1184 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/Caption.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/Caption.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/media/MediaDetailAdapter.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.kt create mode 100644 app/src/main/java/fr/free/nrw/commons/media/MediaDetailProvider.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/media/MwParseResult.java create mode 100644 app/src/main/java/fr/free/nrw/commons/media/MwParseResult.kt diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java index ca7dd3f3b..e14cbbb6f 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java @@ -22,6 +22,7 @@ import fr.free.nrw.commons.contributions.MainActivity; import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.media.MediaDetailProvider; import fr.free.nrw.commons.navtab.NavTab; import java.util.ArrayList; import java.util.Iterator; @@ -29,7 +30,7 @@ import timber.log.Timber; public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements FragmentManager.OnBackStackChangedListener, - MediaDetailPagerFragment.MediaDetailProvider, + MediaDetailProvider, AdapterView.OnItemClickListener, CategoryImagesCallback { private MediaDetailPagerFragment mediaDetails; diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt index 8517db744..c998f96ac 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -8,7 +8,6 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.viewModels -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -22,6 +21,7 @@ import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailProvider import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.utils.handleWebUrl import fr.free.nrw.commons.wikidata.model.WikiSite @@ -36,7 +36,7 @@ import javax.inject.Inject * a particular category on wikimedia commons. */ class CategoryDetailsActivity : BaseActivity(), - MediaDetailPagerFragment.MediaDetailProvider, + MediaDetailProvider, CategoryImagesCallback { private lateinit var supportFragmentManager: FragmentManager 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 index 541cc6e56..537d805c5 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsFragment.kt @@ -43,7 +43,7 @@ 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.media.MediaDetailProvider import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient import fr.free.nrw.commons.nearby.NearbyController import fr.free.nrw.commons.nearby.NearbyNotificationCardView @@ -72,7 +72,6 @@ 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 diff --git a/app/src/main/java/fr/free/nrw/commons/explore/ExploreListRootFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/ExploreListRootFragment.java index f3948caa7..e3ad90119 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/ExploreListRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/ExploreListRootFragment.java @@ -5,7 +5,6 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; @@ -17,10 +16,11 @@ import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.media.MediaDetailProvider; import fr.free.nrw.commons.navtab.NavTab; public class ExploreListRootFragment extends CommonsDaggerSupportFragment implements - MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback { + MediaDetailProvider, CategoryImagesCallback { private MediaDetailPagerFragment mediaDetails; private CategoriesMediaFragment listFragment; 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 abf02758d..31a8e11ba 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 @@ -5,7 +5,6 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; @@ -17,10 +16,11 @@ import fr.free.nrw.commons.databinding.FragmentFeaturedRootBinding; import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; import fr.free.nrw.commons.explore.map.ExploreMapFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.media.MediaDetailProvider; import fr.free.nrw.commons.navtab.NavTab; public class ExploreMapRootFragment extends CommonsDaggerSupportFragment implements - MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback { + MediaDetailProvider, CategoryImagesCallback { private MediaDetailPagerFragment mediaDetails; private ExploreMapFragment mapFragment; diff --git a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java index 1651c720c..b27ffc338 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/SearchActivity.java @@ -6,7 +6,6 @@ import android.os.Bundle; import android.text.TextUtils; import android.view.View; import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import com.jakewharton.rxbinding2.view.RxView; @@ -23,18 +22,14 @@ import fr.free.nrw.commons.explore.models.RecentSearch; import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao; import fr.free.nrw.commons.explore.recentsearches.RecentSearchesFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.media.MediaDetailProvider; import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.utils.FragmentUtils; import fr.free.nrw.commons.utils.ViewUtil; import io.reactivex.android.schedulers.AndroidSchedulers; -import java.util.ArrayList; -import java.util.Arrays; import java.util.Date; -import java.util.List; -import java.util.Locale; import java.util.concurrent.TimeUnit; import javax.inject.Inject; -import kotlin.Pair; import timber.log.Timber; /** @@ -42,7 +37,7 @@ import timber.log.Timber; */ public class SearchActivity extends BaseActivity - implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback { + implements MediaDetailProvider, CategoryImagesCallback { @Inject RecentSearchesDao recentSearchesDao; diff --git a/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.java b/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.java index e4f7ce465..ec5ea42a4 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivity.java @@ -11,7 +11,6 @@ import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import com.google.android.material.snackbar.Snackbar; import fr.free.nrw.commons.Media; @@ -24,6 +23,7 @@ import fr.free.nrw.commons.explore.depictions.child.ChildDepictionsFragment; import fr.free.nrw.commons.explore.depictions.media.DepictedImagesFragment; import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsFragment; import fr.free.nrw.commons.media.MediaDetailPagerFragment; +import fr.free.nrw.commons.media.MediaDetailProvider; import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.upload.structure.depictions.DepictModel; import fr.free.nrw.commons.upload.structure.depictions.DepictedItem; @@ -31,16 +31,12 @@ import fr.free.nrw.commons.wikidata.WikidataConstants; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import javax.inject.Inject; -import kotlin.Pair; /** * Activity to show depiction media, parent classes and child classes of depicted items in Explore */ -public class WikidataItemDetailsActivity extends BaseActivity implements MediaDetailPagerFragment.MediaDetailProvider, +public class WikidataItemDetailsActivity extends BaseActivity implements MediaDetailProvider, CategoryImagesCallback { private FragmentManager supportFragmentManager; private DepictedImagesFragment depictionImagesListFragment; diff --git a/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt b/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt index e19b1b056..e7895f683 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/explore/media/PageableMediaFragment.kt @@ -8,7 +8,7 @@ import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R import fr.free.nrw.commons.category.CategoryImagesCallback import fr.free.nrw.commons.explore.paging.BasePagingFragment -import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.media.MediaDetailProvider import javax.inject.Inject abstract class PageableMediaFragment : diff --git a/app/src/main/java/fr/free/nrw/commons/media/Caption.java b/app/src/main/java/fr/free/nrw/commons/media/Caption.java deleted file mode 100644 index 2cb3b86e1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/Caption.java +++ /dev/null @@ -1,43 +0,0 @@ -package fr.free.nrw.commons.media; - -import com.google.gson.annotations.SerializedName; - -/** - * Model class for parsing Captions when fetching captions using filename in MediaClient - */ -public class Caption { - - /** - * users language in which caption is written - */ - @SerializedName("language") - private String language; - @SerializedName("value") - private String value; - - /** - * No args constructor for use in serialization - */ - public Caption() { - } - - /** - * @param value - * @param language - */ - public Caption(String language, String value) { - super(); - this.language = language; - this.value = value; - } - - @SerializedName("language") - public String getLanguage() { - return language; - } - - @SerializedName("value") - public String getValue() { - return value; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/Caption.kt b/app/src/main/java/fr/free/nrw/commons/media/Caption.kt new file mode 100644 index 000000000..ef2dadc26 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/Caption.kt @@ -0,0 +1,19 @@ +package fr.free.nrw.commons.media + +import com.google.gson.annotations.SerializedName + +/** + * Model class for parsing Captions when fetching captions using filename in MediaClient + */ +class Caption() { + @SerializedName("language") + var language: String? = null + + @SerializedName("value") + var value: String? = null + + constructor(language: String?, value: String?) : this() { + this.language = language + this.value = value + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.java b/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.java deleted file mode 100644 index 759eb2af7..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.java +++ /dev/null @@ -1,67 +0,0 @@ -package fr.free.nrw.commons.media; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.BaseAdapter; -import android.widget.TextView; -import fr.free.nrw.commons.R; -import java.util.List; - -/** - * Adapter for Caption Listview - */ -public class CaptionListViewAdapter extends BaseAdapter { - - List captions; - - public CaptionListViewAdapter(final List captions) { - this.captions = captions; - } - - /** - * @return size of captions list - */ - @Override - public int getCount() { - return captions.size(); - } - - /** - * @return Object at position i - */ - @Override - public Object getItem(final int i) { - return null; - } - - /** - * @return id for current item - */ - @Override - public long getItemId(final int i) { - return 0; - } - - /** - * inflate the view and bind data with UI - */ - @Override - public View getView(final int i, final View view, final ViewGroup viewGroup) { - final TextView captionLanguageTextView; - final TextView captionTextView; - final View captionLayout = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.caption_item, null); - captionLanguageTextView = captionLayout.findViewById(R.id.caption_language_textview); - captionTextView = captionLayout.findViewById(R.id.caption_text); - if (captions.size() == 1 && captions.get(0).getValue().equals("No Caption")) { - captionLanguageTextView.setText(captions.get(i).getLanguage()); - captionTextView.setText(captions.get(i).getValue()); - } else { - captionLanguageTextView.setText(captions.get(i).getLanguage() + ":"); - captionTextView.setText(captions.get(i).getValue()); - } - - return captionLayout; - } - -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.kt b/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.kt new file mode 100644 index 000000000..05e75a23b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/CaptionListViewAdapter.kt @@ -0,0 +1,34 @@ +package fr.free.nrw.commons.media + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView +import fr.free.nrw.commons.R + +/** + * Adapter for Caption Listview + */ +class CaptionListViewAdapter(var captions: List) : BaseAdapter() { + override fun getCount(): Int = captions.size + + override fun getItem(i: Int): Any? = null + + override fun getItemId(i: Int): Long = 0 + + override fun getView(i: Int, view: View, viewGroup: ViewGroup): View { + val captionLayout = LayoutInflater.from(viewGroup.context).inflate(R.layout.caption_item, null) + val captionLanguageTextView = captionLayout.findViewById(R.id.caption_language_textview) + val captionTextView = captionLayout.findViewById(R.id.caption_text) + if (captions.size == 1 && captions[0].value == "No Caption") { + captionLanguageTextView.text = captions[i].language + captionTextView.text = captions[i].value + } else { + captionLanguageTextView.text = captions[i].language + ":" + captionTextView.text = captions[i].value + } + + return captionLayout + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java b/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java deleted file mode 100644 index af26f0c72..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/CommonsWikibaseItem.java +++ /dev/null @@ -1,76 +0,0 @@ -package fr.free.nrw.commons.media; - -import com.google.gson.annotations.SerializedName; - -import java.util.Map; - - -/** - * Represents the Wikibase item associated with a Wikimedia Commons file. - * For instance the Wikibase item M63996 represents the Commons file "Paul Cézanne - The Pigeon Tower at Bellevue - 1936.19 - Cleveland Museum of Art.jpg" - */ -public class CommonsWikibaseItem { - - @SerializedName("type") - private String type; - @SerializedName("id") - private String id; - @SerializedName("labels") - private Map labels; - @SerializedName("statements") - private Object statements = null; - - /** - * No args constructor for use in serialization - */ - public CommonsWikibaseItem() { - } - - /** - * @param id - * @param statements - * @param labels - * @param type - */ - public CommonsWikibaseItem(String type, String id, Map labels, Object statements) { - super(); - this.type = type; - this.id = id; - this.labels = labels; - this.statements = statements; - } - - /** - * Ex: "mediainfo - */ - @SerializedName("type") - public String getType() { - return type; - } - - /** - * @return Wikibase Id - */ - @SerializedName("id") - public String getId() { - return id; - } - - /** - * @return value of captions - */ - @SerializedName("labels") - public Map getLabels() { - return labels; - } - - /** - * Contains the Depicts item - */ - @SerializedName("statements") - public Object getStatements() { - return statements; - } - - -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.java b/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.java deleted file mode 100644 index bbb3b73a8..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.java +++ /dev/null @@ -1,236 +0,0 @@ -package fr.free.nrw.commons.media; - -import android.net.Uri; -import android.os.Looper; -import android.os.SystemClock; -import androidx.annotation.Nullable; -import com.facebook.imagepipeline.common.BytesRange; -import com.facebook.imagepipeline.image.EncodedImage; -import com.facebook.imagepipeline.producers.BaseNetworkFetcher; -import com.facebook.imagepipeline.producers.BaseProducerContextCallbacks; -import com.facebook.imagepipeline.producers.Consumer; -import com.facebook.imagepipeline.producers.FetchState; -import com.facebook.imagepipeline.producers.NetworkFetcher; -import com.facebook.imagepipeline.producers.ProducerContext; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.kvstore.JsonKvStore; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Executor; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import okhttp3.CacheControl; -import okhttp3.Call; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import timber.log.Timber; - -// Custom implementation of Fresco's Network fetcher to skip downloading of images when limited connection mode is enabled -// https://github.com/facebook/fresco/blob/master/imagepipeline-backends/imagepipeline-okhttp3/src/main/java/com/facebook/imagepipeline/backends/okhttp3/OkHttpNetworkFetcher.java -@Singleton -public class CustomOkHttpNetworkFetcher - extends BaseNetworkFetcher { - - private static final String QUEUE_TIME = "queue_time"; - private static final String FETCH_TIME = "fetch_time"; - private static final String TOTAL_TIME = "total_time"; - private static final String IMAGE_SIZE = "image_size"; - private final Call.Factory mCallFactory; - private final @Nullable - CacheControl mCacheControl; - private final Executor mCancellationExecutor; - private final JsonKvStore defaultKvStore; - - /** - * @param okHttpClient client to use - */ - @Inject - public CustomOkHttpNetworkFetcher(final OkHttpClient okHttpClient, - @Named("default_preferences") final JsonKvStore defaultKvStore) { - this(okHttpClient, okHttpClient.dispatcher().executorService(), defaultKvStore); - } - - /** - * @param callFactory custom {@link Call.Factory} for fetching image from the network - * @param cancellationExecutor executor on which fetching cancellation is performed if - * cancellation is requested from the UI Thread - */ - public CustomOkHttpNetworkFetcher(final Call.Factory callFactory, - final Executor cancellationExecutor, - final JsonKvStore defaultKvStore) { - this(callFactory, cancellationExecutor, defaultKvStore, true); - } - - /** - * @param callFactory custom {@link Call.Factory} for fetching image from the network - * @param cancellationExecutor executor on which fetching cancellation is performed if - * cancellation is requested from the UI Thread - * @param disableOkHttpCache true if network requests should not be cached by OkHttp - */ - public CustomOkHttpNetworkFetcher( - final Call.Factory callFactory, final Executor cancellationExecutor, - final JsonKvStore defaultKvStore, - final boolean disableOkHttpCache) { - this.defaultKvStore = defaultKvStore; - mCallFactory = callFactory; - mCancellationExecutor = cancellationExecutor; - mCacheControl = disableOkHttpCache ? new CacheControl.Builder().noStore().build() : null; - } - - @Override - public OkHttpNetworkFetchState createFetchState( - final Consumer consumer, final ProducerContext context) { - return new OkHttpNetworkFetchState(consumer, context); - } - - @Override - public void fetch( - final OkHttpNetworkFetchState fetchState, final NetworkFetcher.Callback callback) { - fetchState.submitTime = SystemClock.elapsedRealtime(); - final Uri uri = fetchState.getUri(); - - try { - if (defaultKvStore - .getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)) { - Timber.d("Skipping loading of image as limited connection mode is enabled"); - callback.onFailure( - new Exception("Failing image request as limited connection mode is enabled")); - return; - } - final Request.Builder requestBuilder = new Request.Builder().url(uri.toString()).get(); - - if (mCacheControl != null) { - requestBuilder.cacheControl(mCacheControl); - } - - final BytesRange bytesRange = fetchState.getContext().getImageRequest().getBytesRange(); - if (bytesRange != null) { - requestBuilder.addHeader("Range", bytesRange.toHttpRangeHeaderValue()); - } - - fetchWithRequest(fetchState, callback, requestBuilder.build()); - } catch (final Exception e) { - // handle error while creating the request - callback.onFailure(e); - } - } - - @Override - public void onFetchCompletion(final OkHttpNetworkFetchState fetchState, final int byteSize) { - fetchState.fetchCompleteTime = SystemClock.elapsedRealtime(); - } - - @Override - public Map getExtraMap(final OkHttpNetworkFetchState fetchState, - final int byteSize) { - final Map extraMap = new HashMap<>(4); - extraMap.put(QUEUE_TIME, Long.toString(fetchState.responseTime - fetchState.submitTime)); - extraMap - .put(FETCH_TIME, Long.toString(fetchState.fetchCompleteTime - fetchState.responseTime)); - extraMap - .put(TOTAL_TIME, Long.toString(fetchState.fetchCompleteTime - fetchState.submitTime)); - extraMap.put(IMAGE_SIZE, Integer.toString(byteSize)); - return extraMap; - } - - protected void fetchWithRequest( - final OkHttpNetworkFetchState fetchState, - final NetworkFetcher.Callback callback, - final Request request) { - final Call call = mCallFactory.newCall(request); - - fetchState - .getContext() - .addCallbacks( - new BaseProducerContextCallbacks() { - @Override - public void onCancellationRequested() { - onFetchCancellationRequested(call); - } - }); - - call.enqueue( - new okhttp3.Callback() { - @Override - public void onResponse(final Call call, final Response response) { - onFetchResponse(fetchState, call, response, callback); - } - - @Override - public void onFailure(final Call call, final IOException e) { - handleException(call, e, callback); - } - }); - } - - private void onFetchCancellationRequested(final Call call) { - if (Looper.myLooper() != Looper.getMainLooper()) { - call.cancel(); - } else { - mCancellationExecutor.execute(call::cancel); - } - } - - private void onFetchResponse(final OkHttpNetworkFetchState fetchState, final Call call, - final Response response, - final NetworkFetcher.Callback callback) { - fetchState.responseTime = SystemClock.elapsedRealtime(); - try (final ResponseBody body = response.body()) { - if (!response.isSuccessful()) { - handleException( - call, new IOException("Unexpected HTTP code " + response), - callback); - return; - } - - final BytesRange responseRange = - BytesRange.fromContentRangeHeader(response.header("Content-Range")); - if (responseRange != null - && !(responseRange.from == 0 - && responseRange.to == BytesRange.TO_END_OF_CONTENT)) { - // Only treat as a partial image if the range is not all of the content - fetchState.setResponseBytesRange(responseRange); - fetchState.setOnNewResultStatusFlags(Consumer.IS_PARTIAL_RESULT); - } - - long contentLength = body.contentLength(); - if (contentLength < 0) { - contentLength = 0; - } - callback.onResponse(body.byteStream(), (int) contentLength); - } catch (final Exception e) { - handleException(call, e, callback); - } - } - - /** - * Handles exceptions. - * - *

OkHttp notifies callers of cancellations via an IOException. If IOException is caught - * after request cancellation, then the exception is interpreted as successful cancellation and - * onCancellation is called. Otherwise onFailure is called. - */ - private void handleException(final Call call, final Exception e, final Callback callback) { - if (call.isCanceled()) { - callback.onCancellation(); - } else { - callback.onFailure(e); - } - } - - public static class OkHttpNetworkFetchState extends FetchState { - - public long submitTime; - public long responseTime; - public long fetchCompleteTime; - - public OkHttpNetworkFetchState( - final Consumer consumer, final ProducerContext producerContext) { - super(consumer, producerContext); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt b/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt new file mode 100644 index 000000000..c8de4022b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcher.kt @@ -0,0 +1,199 @@ +package fr.free.nrw.commons.media + +import android.os.Looper +import android.os.SystemClock +import com.facebook.imagepipeline.common.BytesRange +import com.facebook.imagepipeline.image.EncodedImage +import com.facebook.imagepipeline.producers.BaseNetworkFetcher +import com.facebook.imagepipeline.producers.BaseProducerContextCallbacks +import com.facebook.imagepipeline.producers.Consumer +import com.facebook.imagepipeline.producers.FetchState +import com.facebook.imagepipeline.producers.NetworkFetcher +import com.facebook.imagepipeline.producers.ProducerContext +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.kvstore.JsonKvStore +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import timber.log.Timber +import java.io.IOException +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +// Custom implementation of Fresco's Network fetcher to skip downloading of images when limited connection mode is enabled +// https://github.com/facebook/fresco/blob/master/imagepipeline-backends/imagepipeline-okhttp3/src/main/java/com/facebook/imagepipeline/backends/okhttp3/OkHttpNetworkFetcher.java +@Singleton +class CustomOkHttpNetworkFetcher +@JvmOverloads constructor( + private val mCallFactory: Call.Factory, + private val mCancellationExecutor: Executor, + private val defaultKvStore: JsonKvStore, + disableOkHttpCache: Boolean = true +) : BaseNetworkFetcher() { + + private val mCacheControl = + if (disableOkHttpCache) CacheControl.Builder().noStore().build() else null + private val isLimitedConnectionMode: Boolean + get() = defaultKvStore.getBoolean( + CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, + false + ) + + /** + * @param okHttpClient client to use + */ + @Inject + constructor( + okHttpClient: OkHttpClient, + @Named("default_preferences") defaultKvStore: JsonKvStore + ) : this(okHttpClient, okHttpClient.dispatcher.executorService, defaultKvStore) + + /** + * @param mCallFactory custom [Call.Factory] for fetching image from the network + * @param mCancellationExecutor executor on which fetching cancellation is performed if + * cancellation is requested from the UI Thread + * @param disableOkHttpCache true if network requests should not be cached by OkHttp + */ + override fun createFetchState(consumer: Consumer, context: ProducerContext) = + OkHttpNetworkFetchState(consumer, context) + + override fun fetch( + fetchState: OkHttpNetworkFetchState, callback: NetworkFetcher.Callback + ) { + fetchState.submitTime = SystemClock.elapsedRealtime() + + try { + if (isLimitedConnectionMode) { + Timber.d("Skipping loading of image as limited connection mode is enabled") + callback.onFailure(Exception("Failing image request as limited connection mode is enabled")) + return + } + + val requestBuilder = Request.Builder().url(fetchState.uri.toString()).get() + + if (mCacheControl != null) { + requestBuilder.cacheControl(mCacheControl) + } + + val bytesRange = fetchState.context.imageRequest.bytesRange + if (bytesRange != null) { + requestBuilder.addHeader("Range", bytesRange.toHttpRangeHeaderValue()) + } + + fetchWithRequest(fetchState, callback, requestBuilder.build()) + } catch (e: Exception) { + // handle error while creating the request + callback.onFailure(e) + } + } + + override fun onFetchCompletion(fetchState: OkHttpNetworkFetchState, byteSize: Int) { + fetchState.fetchCompleteTime = SystemClock.elapsedRealtime() + } + + override fun getExtraMap(fetchState: OkHttpNetworkFetchState, byteSize: Int) = + fetchState.toExtraMap(byteSize) + + private fun fetchWithRequest( + fetchState: OkHttpNetworkFetchState, callback: NetworkFetcher.Callback, request: Request + ) { + val call = mCallFactory.newCall(request) + + fetchState.context.addCallbacks(object : BaseProducerContextCallbacks() { + override fun onCancellationRequested() { + onFetchCancellationRequested(call) + } + }) + + call.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) = + onFetchResponse(fetchState, call, response, callback) + + override fun onFailure(call: Call, e: IOException) = + handleException(call, e, callback) + }) + } + + private fun onFetchCancellationRequested(call: Call) { + if (Looper.myLooper() != Looper.getMainLooper()) { + call.cancel() + } else { + mCancellationExecutor.execute { call.cancel() } + } + } + + private fun onFetchResponse( + fetchState: OkHttpNetworkFetchState, + call: Call, + response: Response, + callback: NetworkFetcher.Callback + ) { + fetchState.responseTime = SystemClock.elapsedRealtime() + try { + response.body.use { body -> + if (!response.isSuccessful) { + handleException(call, IOException("Unexpected HTTP code $response"), callback) + return + } + val responseRange = + BytesRange.fromContentRangeHeader(response.header("Content-Range")) + if (responseRange != null && !(responseRange.from == 0 && responseRange.to == BytesRange.TO_END_OF_CONTENT)) { + // Only treat as a partial image if the range is not all of the content + fetchState.responseBytesRange = responseRange + fetchState.onNewResultStatusFlags = Consumer.IS_PARTIAL_RESULT + } + + var contentLength = body!!.contentLength() + if (contentLength < 0) { + contentLength = 0 + } + callback.onResponse(body.byteStream(), contentLength.toInt()) + } + } catch (e: Exception) { + handleException(call, e, callback) + } + } + + /** + * Handles exceptions. + * + * OkHttp notifies callers of cancellations via an IOException. If IOException is caught + * after request cancellation, then the exception is interpreted as successful cancellation and + * onCancellation is called. Otherwise onFailure is called. + */ + private fun handleException(call: Call, e: Exception, callback: NetworkFetcher.Callback) { + if (call.isCanceled()) { + callback.onCancellation() + } else { + callback.onFailure(e) + } + } +} + +class OkHttpNetworkFetchState( + consumer: Consumer?, producerContext: ProducerContext? +) : FetchState(consumer, producerContext) { + var submitTime: Long = 0 + var responseTime: Long = 0 + var fetchCompleteTime: Long = 0 + + fun toExtraMap(byteSize: Int) = buildMap { + put(QUEUE_TIME, (responseTime - submitTime).toString()) + put(FETCH_TIME, (fetchCompleteTime - responseTime).toString()) + put(TOTAL_TIME, (fetchCompleteTime - submitTime).toString()) + put(IMAGE_SIZE, byteSize.toString()) + } + + companion object { + private const val QUEUE_TIME = "queue_time" + private const val FETCH_TIME = "fetch_time" + private const val TOTAL_TIME = "total_time" + private const val IMAGE_SIZE = "image_size" + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailAdapter.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailAdapter.kt new file mode 100644 index 000000000..ccc176154 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailAdapter.kt @@ -0,0 +1,76 @@ +package fr.free.nrw.commons.media + +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentStatePagerAdapter +import fr.free.nrw.commons.media.MediaDetailFragment.Companion.forMedia +import timber.log.Timber + +// FragmentStatePagerAdapter allows user to swipe across collection of images (no. of images undetermined) +class MediaDetailAdapter( + val mediaDetailPagerFragment: MediaDetailPagerFragment, + fm: FragmentManager +) : FragmentStatePagerAdapter(fm) { + /** + * Keeps track of the current displayed fragment. + */ + private var currentFragment: Fragment? = null + + override fun getItem(i: Int): Fragment { + if (i == 0) { + // See bug https://code.google.com/p/android/issues/detail?id=27526 + if (mediaDetailPagerFragment.activity == null) { + Timber.d("Skipping getItem. Returning as activity is destroyed!") + return Fragment() + } + mediaDetailPagerFragment.binding!!.mediaDetailsPager.postDelayed( + { mediaDetailPagerFragment.requireActivity().invalidateOptionsMenu() }, 5 + ) + } + return if (mediaDetailPagerFragment.isFromFeaturedRootFragment) { + forMedia( + mediaDetailPagerFragment.position + i, + mediaDetailPagerFragment.editable, mediaDetailPagerFragment.isFeaturedImage, + mediaDetailPagerFragment.isWikipediaButtonDisplayed + ) + } else { + forMedia( + i, mediaDetailPagerFragment.editable, + mediaDetailPagerFragment.isFeaturedImage, + mediaDetailPagerFragment.isWikipediaButtonDisplayed + ) + } + } + + override fun getCount(): Int { + if (mediaDetailPagerFragment.activity == null) { + Timber.d("Skipping getCount. Returning as activity is destroyed!") + return 0 + } + return mediaDetailPagerFragment.mediaDetailProvider!!.getTotalMediaCount() + } + + /** + * If current fragment is of type MediaDetailFragment, return it, otherwise return null. + * + * @return MediaDetailFragment + */ + val currentMediaDetailFragment: MediaDetailFragment? + get() = currentFragment as? MediaDetailFragment + + /** + * Called to inform the adapter of which item is currently considered to be the "primary", that + * is the one show to the user as the current page. + */ + override fun setPrimaryItem( + container: ViewGroup, position: Int, + obj: Any + ) { + // Update the current fragment if changed + if (currentFragment !== obj) { + currentFragment = (obj as Fragment) + } + super.setPrimaryItem(container, position, obj) + } +} 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 5980e1fb5..07574067c 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 @@ -77,7 +77,6 @@ import fr.free.nrw.commons.CommonsApplication.Companion.instance import fr.free.nrw.commons.Media import fr.free.nrw.commons.MediaDataExtractor import fr.free.nrw.commons.R -import fr.free.nrw.commons.utils.UnderlineUtils import fr.free.nrw.commons.actions.ThanksClient import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException @@ -102,7 +101,6 @@ import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.language.AppLanguageLookUpTable import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.locationpicker.LocationPicker -import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider import fr.free.nrw.commons.profile.ProfileActivity import fr.free.nrw.commons.review.ReviewHelper import fr.free.nrw.commons.settings.Prefs 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 deleted file mode 100644 index 324d5867b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ /dev/null @@ -1,678 +0,0 @@ -package fr.free.nrw.commons.media; - -import static fr.free.nrw.commons.utils.UrlUtilsKt.handleWebUrl; - -import android.os.Handler; -import android.os.Looper; -import android.widget.ProgressBar; -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -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.Toast; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentStatePagerAdapter; -import androidx.viewpager.widget.ViewPager; -import com.google.android.material.snackbar.Snackbar; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.utils.ClipboardUtils; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.bookmarks.models.Bookmark; -import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider; -import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao; -import fr.free.nrw.commons.contributions.Contribution; -import fr.free.nrw.commons.contributions.MainActivity; -import fr.free.nrw.commons.databinding.FragmentMediaDetailPagerBinding; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient; -import fr.free.nrw.commons.profile.ProfileActivity; -import fr.free.nrw.commons.utils.DownloadUtils; -import fr.free.nrw.commons.utils.ImageUtils; -import fr.free.nrw.commons.utils.NetworkUtils; -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 java.net.URL; -import java.util.ArrayList; -import java.util.Objects; -import java.util.concurrent.Callable; -import javax.inject.Inject; -import timber.log.Timber; - -public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment implements ViewPager.OnPageChangeListener, MediaDetailFragment.Callback { - - @Inject BookmarkPicturesDao bookmarkDao; - - @Inject - protected OkHttpJsonApiClient okHttpJsonApiClient; - - @Inject - protected SessionManager sessionManager; - - private static CompositeDisposable compositeDisposable = new CompositeDisposable(); - - private FragmentMediaDetailPagerBinding binding; - - private boolean editable; - private boolean isFeaturedImage; - private boolean isWikipediaButtonDisplayed; - MediaDetailAdapter adapter; - private Bookmark bookmark; - private MediaDetailProvider provider; - private boolean isFromFeaturedRootFragment; - private int position; - - /** - * ProgressBar used to indicate the loading status of media items. - */ - private ProgressBar imageProgressBar; - - private ArrayList removedItems=new ArrayList(); - - public void clearRemoved(){ - removedItems.clear(); - } - public ArrayList getRemovedItems() { - return removedItems; - } - - - /** - * Use this factory method to create a new instance of this fragment using the provided - * parameters. - * - * This method will create a new instance of MediaDetailPagerFragment and the arguments will be - * saved to a bundle which will be later available in the {@link #onCreate(Bundle)} - * @param editable - * @param isFeaturedImage - * @return - */ - public static MediaDetailPagerFragment newInstance(boolean editable, boolean isFeaturedImage) { - MediaDetailPagerFragment mediaDetailPagerFragment = new MediaDetailPagerFragment(); - Bundle args = new Bundle(); - args.putBoolean("is_editable", editable); - args.putBoolean("is_featured_image", isFeaturedImage); - mediaDetailPagerFragment.setArguments(args); - return mediaDetailPagerFragment; - } - - public MediaDetailPagerFragment() { - // Required empty public constructor - }; - - - @Override - public View onCreateView(LayoutInflater inflater, - ViewGroup container, - Bundle savedInstanceState) { - binding = FragmentMediaDetailPagerBinding.inflate(inflater, container, false); - binding.mediaDetailsPager.addOnPageChangeListener(this); - // Initialize the ProgressBar by finding it in the layout - imageProgressBar = binding.getRoot().findViewById(R.id.itemProgressBar); - adapter = new MediaDetailAdapter(getChildFragmentManager()); - - // ActionBar is now supported in both activities - if this crashes something is quite wrong - final ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - } - else { - throw new AssertionError("Action bar should not be null!"); - } - - // If fragment is associated with ProfileActivity, then hide the tabLayout - if (getActivity() instanceof ProfileActivity) { - ((ProfileActivity)getActivity()).setTabLayoutVisibility(false); - } - - // Else if fragment is associated with MainActivity then hide that tab layout - else if (getActivity() instanceof MainActivity) { - ((MainActivity)getActivity()).hideTabs(); - } - - binding.mediaDetailsPager.setAdapter(adapter); - - if (savedInstanceState != null) { - final int pageNumber = savedInstanceState.getInt("current-page"); - binding.mediaDetailsPager.setCurrentItem(pageNumber, false); - getActivity().invalidateOptionsMenu(); - } - adapter.notifyDataSetChanged(); - - return binding.getRoot(); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("current-page", binding.mediaDetailsPager.getCurrentItem()); - outState.putBoolean("editable", editable); - outState.putBoolean("isFeaturedImage", isFeaturedImage); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (savedInstanceState != null) { - editable = savedInstanceState.getBoolean("editable", false); - isFeaturedImage = savedInstanceState.getBoolean("isFeaturedImage", false); - - } - setHasOptionsMenu(true); - initProvider(); - } - - /** - * initialise the provider, based on from where the fragment was started, as in from an activity - * or a fragment - */ - private void initProvider() { - if (getParentFragment() instanceof MediaDetailProvider) { - provider = (MediaDetailProvider) getParentFragment(); - } else if (getActivity() instanceof MediaDetailProvider) { - provider = (MediaDetailProvider) getActivity(); - } else { - throw new ClassCastException("Parent must implement MediaDetailProvider"); - } - } - - public MediaDetailProvider getMediaDetailProvider() { - return provider; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (getActivity() == null) { - Timber.d("Returning as activity is destroyed!"); - return true; - } - - Media m = provider.getMediaAtPosition(binding.mediaDetailsPager.getCurrentItem()); - MediaDetailFragment mediaDetailFragment = this.adapter.getCurrentMediaDetailFragment(); - switch (item.getItemId()) { - case R.id.menu_bookmark_current_image: - boolean bookmarkExists = bookmarkDao.updateBookmark(bookmark); - Snackbar snackbar = bookmarkExists ? Snackbar.make(getView(), R.string.add_bookmark, Snackbar.LENGTH_LONG) : Snackbar.make(getView(), R.string.remove_bookmark, Snackbar.LENGTH_LONG); - snackbar.show(); - updateBookmarkState(item); - return true; - case R.id.menu_copy_link: - String uri = m.getPageTitle().getCanonicalUri(); - ClipboardUtils.copy("shareLink", uri, requireContext()); - Timber.d("Copied share link to clipboard: %s", uri); - Toast.makeText(requireContext(), getString(R.string.menu_link_copied), - Toast.LENGTH_SHORT).show(); - return true; - case R.id.menu_share_current_image: - Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_TEXT, m.getDisplayTitle() + " \n" + m.getPageTitle().getCanonicalUri()); - startActivity(Intent.createChooser(shareIntent, "Share image via...")); - - //Add media detail to backstack when the share button is clicked - //So that when the share is cancelled or completed the media detail page is on top - // of back stack fixing:https://github.com/commons-app/apps-android-commons/issues/2296 - FragmentManager supportFragmentManager = getActivity().getSupportFragmentManager(); - if (supportFragmentManager.getBackStackEntryCount() < 2) { - supportFragmentManager - .beginTransaction() - .addToBackStack(MediaDetailPagerFragment.class.getName()) - .commit(); - supportFragmentManager.executePendingTransactions(); - } - return true; - case R.id.menu_browser_current_image: - // View in browser - handleWebUrl(requireContext(), Uri.parse(m.getPageTitle().getMobileUri())); - return true; - case R.id.menu_download_current_image: - // Download - if (!NetworkUtils.isInternetConnectionEstablished(getActivity())) { - ViewUtil.showShortSnackbar(getView(), R.string.no_internet); - return false; - } - DownloadUtils.downloadMedia(getActivity(), m); - return true; - case R.id.menu_set_as_wallpaper: - // Set wallpaper - setWallpaper(m); - return true; - case R.id.menu_set_as_avatar: - // Set avatar - setAvatar(m); - return true; - case R.id.menu_view_user_page: - if (m != null && m.getUser() != null) { - ProfileActivity.startYourself(getActivity(), m.getUser(), - !Objects.equals(sessionManager.getUserName(), m.getUser())); - } - return true; - case R.id.menu_view_report: - showReportDialog(m); - case R.id.menu_view_set_white_background: - if (mediaDetailFragment != null) { - mediaDetailFragment.onImageBackgroundChanged(ContextCompat.getColor(getContext(), R.color.white)); - } - return true; - case R.id.menu_view_set_black_background: - if (mediaDetailFragment != null) { - mediaDetailFragment.onImageBackgroundChanged(ContextCompat.getColor(getContext(), R.color.black)); - } - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - private void showReportDialog(final Media media) { - if (media == null) { - return; - } - final AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity()); - final String[] values = requireContext().getResources() - .getStringArray(R.array.report_violation_options); - builder.setTitle(R.string.report_violation); - builder.setItems(R.array.report_violation_options, (dialog, which) -> { - sendReportEmail(media, values[which]); - }); - builder.setNegativeButton(R.string.cancel, (dialog, which) -> {}); - builder.setCancelable(false); - builder.show(); - } - - private void sendReportEmail(final Media media, final String type) { - final String technicalInfo = getTechInfo(media, type); - - final Intent feedbackIntent = new Intent(Intent.ACTION_SENDTO); - feedbackIntent.setType("message/rfc822"); - feedbackIntent.setData(Uri.parse("mailto:")); - feedbackIntent.putExtra(Intent.EXTRA_EMAIL, - new String[]{CommonsApplication.REPORT_EMAIL}); - feedbackIntent.putExtra(Intent.EXTRA_SUBJECT, - CommonsApplication.REPORT_EMAIL_SUBJECT); - feedbackIntent.putExtra(Intent.EXTRA_TEXT, technicalInfo); - try { - startActivity(feedbackIntent); - } catch (final ActivityNotFoundException e) { - Toast.makeText(getActivity(), R.string.no_email_client, Toast.LENGTH_SHORT).show(); - } - } - - private String getTechInfo(final Media media, final String type) { - final StringBuilder builder = new StringBuilder(); - - builder.append("Report type: ") - .append(type) - .append("\n\n"); - - builder.append("Image that you want to report: ") - .append(media.getImageUrl()) - .append("\n\n"); - - builder.append("User that you want to report: ") - .append(media.getUser()) - .append("\n\n"); - - if (sessionManager.getUserName() != null) { - builder.append("Your username: ") - .append(sessionManager.getUserName()) - .append("\n\n"); - } - - builder.append("Violation reason: ") - .append("\n"); - - builder.append("----------------------------------------------") - .append("\n") - .append("(please write reason here)") - .append("\n") - .append("----------------------------------------------") - .append("\n\n") - .append("Thank you for your report! Our team will investigate as soon as possible.") - .append("\n") - .append("Please note that images also have a `Nominate for deletion` button."); - - return builder.toString(); - } - - /** - * Set the media as the device's wallpaper if the imageUrl is not null - * Fails silently if setting the wallpaper fails - * @param media - */ - private void setWallpaper(Media media) { - if (media.getImageUrl() == null || media.getImageUrl().isEmpty()) { - Timber.d("Media URL not present"); - return; - } - ImageUtils.setWallpaperFromImageUrl(getActivity(), Uri.parse(media.getImageUrl())); - } - - /** - * Set the media as user's leaderboard avatar - * @param media - */ - private void setAvatar(Media media) { - if (media.getImageUrl() == null || media.getImageUrl().isEmpty()) { - Timber.d("Media URL not present"); - return; - } - ImageUtils.setAvatarFromImageUrl(getActivity(), media.getImageUrl(), - Objects.requireNonNull(sessionManager.getCurrentAccount()).name, - okHttpJsonApiClient, compositeDisposable); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - if (!editable) { // Disable menu options for editable views - menu.clear(); // see http://stackoverflow.com/a/8495697/17865 - inflater.inflate(R.menu.fragment_image_detail, menu); - if (binding.mediaDetailsPager != null) { - MediaDetailProvider provider = getMediaDetailProvider(); - if(provider == null) { - return; - } - final int position; - if (isFromFeaturedRootFragment) { - position = this.position; - } else { - position = binding.mediaDetailsPager.getCurrentItem(); - } - - Media m = provider.getMediaAtPosition(position); - if (m != null) { - // Enable default set of actions, then re-enable different set of actions only if it is a failed contrib - menu.findItem(R.id.menu_browser_current_image).setEnabled(true).setVisible(true); - menu.findItem(R.id.menu_copy_link).setEnabled(true).setVisible(true); - menu.findItem(R.id.menu_share_current_image).setEnabled(true).setVisible(true); - menu.findItem(R.id.menu_download_current_image).setEnabled(true).setVisible(true); - menu.findItem(R.id.menu_bookmark_current_image).setEnabled(true).setVisible(true); - menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(true).setVisible(true); - if (m.getUser() != null) { - menu.findItem(R.id.menu_view_user_page).setEnabled(true).setVisible(true); - } - - try { - URL mediaUrl = new URL(m.getImageUrl()); - this.handleBackgroundColorMenuItems( - () -> BitmapFactory.decodeStream(mediaUrl.openConnection().getInputStream()), - menu - ); - } catch (Exception e) { - Timber.e("Cant detect media transparency"); - } - - // Initialize bookmark object - bookmark = new Bookmark( - m.getFilename(), - m.getAuthorOrUser(), - BookmarkPicturesContentProvider.uriForName(m.getFilename()) - ); - updateBookmarkState(menu.findItem(R.id.menu_bookmark_current_image)); - final Integer contributionState = provider.getContributionStateAt(position); - if (contributionState != null) { - switch (contributionState) { - case Contribution.STATE_FAILED: - case Contribution.STATE_IN_PROGRESS: - case Contribution.STATE_QUEUED: - menu.findItem(R.id.menu_browser_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_copy_link).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_share_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_download_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_bookmark_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(false) - .setVisible(false); - break; - case Contribution.STATE_COMPLETED: - // Default set of menu items works fine. Treat same as regular media object - break; - } - } - } else { - menu.findItem(R.id.menu_browser_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_copy_link).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_share_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_download_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_bookmark_current_image).setEnabled(false) - .setVisible(false); - menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(false) - .setVisible(false); - } - - if (!sessionManager.isUserLoggedIn()) { - menu.findItem(R.id.menu_set_as_avatar).setVisible(false); - } - - } - } - } - - /** - * Decide wether or not we should display the background color menu items - * We display them if the image is transparent - * @param getBitmap - * @param menu - */ - private void handleBackgroundColorMenuItems(Callable getBitmap, Menu menu) { - Observable.fromCallable( - getBitmap - ).subscribeOn(Schedulers.newThread()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(image -> { - if (image.hasAlpha()) { - menu.findItem(R.id.menu_view_set_white_background).setVisible(true).setEnabled(true); - menu.findItem(R.id.menu_view_set_black_background).setVisible(true).setEnabled(true); - } - }); - } - - private void updateBookmarkState(MenuItem item) { - boolean isBookmarked = bookmarkDao.findBookmark(bookmark); - if(isBookmarked) { - if(removedItems.contains(binding.mediaDetailsPager.getCurrentItem())) { - removedItems.remove(new Integer(binding.mediaDetailsPager.getCurrentItem())); - } - } - else { - if(!removedItems.contains(binding.mediaDetailsPager.getCurrentItem())) { - removedItems.add(binding.mediaDetailsPager.getCurrentItem()); - } - } - int icon = isBookmarked ? R.drawable.menu_ic_round_star_filled_24px : R.drawable.menu_ic_round_star_border_24px; - item.setIcon(icon); - } - - public void showImage(int i, boolean isWikipediaButtonDisplayed) { - this.isWikipediaButtonDisplayed = isWikipediaButtonDisplayed; - setViewPagerCurrentItem(i); - } - - public void showImage(int i) { - setViewPagerCurrentItem(i); - } - - /** - * This function waits for the item to load then sets the item to current item - * @param position current item that to be shown - */ - private void setViewPagerCurrentItem(int position) { - - final Handler handler = new Handler(Looper.getMainLooper()); - final Runnable runnable = new Runnable() { - @Override - public void run() { - // Show the ProgressBar while waiting for the item to load - imageProgressBar.setVisibility(View.VISIBLE); - // Check if the adapter has enough items loaded - if(adapter.getCount() > position){ - // Set the current item in the ViewPager - binding.mediaDetailsPager.setCurrentItem(position, false); - // Hide the ProgressBar once the item is loaded - imageProgressBar.setVisibility(View.GONE); - } else { - // If the item is not ready yet, post the Runnable again - handler.post(this); - } - } - }; - // Start the Runnable - handler.post(runnable); - } - - /** - * The method notify the viewpager that number of items have changed. - */ - public void notifyDataSetChanged(){ - if (null != adapter) { - adapter.notifyDataSetChanged(); - } - } - - @Override - public void onPageScrolled(int i, float v, int i2) { - if(getActivity() == null) { - Timber.d("Returning as activity is destroyed!"); - return; - } - - getActivity().invalidateOptionsMenu(); - } - - @Override - public void onPageSelected(int i) { - } - - @Override - public void onPageScrollStateChanged(int i) { - } - - public void onDataSetChanged() { - if (null != adapter) { - adapter.notifyDataSetChanged(); - } - } - - /** - * Called after the media is nominated for deletion - * - * @param index item position that has been nominated - */ - @Override - public void nominatingForDeletion(int index) { - provider.refreshNominatedMedia(index); - } - - public interface MediaDetailProvider { - Media getMediaAtPosition(int i); - - int getTotalMediaCount(); - - Integer getContributionStateAt(int position); - - // Reload media detail fragment once media is nominated - void refreshNominatedMedia(int index); - } - - //FragmentStatePagerAdapter allows user to swipe across collection of images (no. of images undetermined) - private class MediaDetailAdapter extends FragmentStatePagerAdapter { - - /** - * Keeps track of the current displayed fragment. - */ - private Fragment mCurrentFragment; - - public MediaDetailAdapter(FragmentManager fm) { - super(fm); - } - - @Override - public Fragment getItem(int i) { - if (i == 0) { - // See bug https://code.google.com/p/android/issues/detail?id=27526 - if(getActivity() == null) { - Timber.d("Skipping getItem. Returning as activity is destroyed!"); - return null; - } - binding.mediaDetailsPager.postDelayed(() -> getActivity().invalidateOptionsMenu(), 5); - } - if (isFromFeaturedRootFragment) { - return MediaDetailFragment.forMedia(position+i, editable, isFeaturedImage, isWikipediaButtonDisplayed); - } else { - return MediaDetailFragment.forMedia(i, editable, isFeaturedImage, isWikipediaButtonDisplayed); - } - } - - @Override - public int getCount() { - if (getActivity() == null) { - Timber.d("Skipping getCount. Returning as activity is destroyed!"); - return 0; - } - return provider.getTotalMediaCount(); - } - - /** - * Get the currently displayed fragment. - * @return - */ - public Fragment getCurrentFragment() { - return mCurrentFragment; - } - - /** - * If current fragment is of type MediaDetailFragment, return it, otherwise return null. - * @return MediaDetailFragment - */ - public MediaDetailFragment getCurrentMediaDetailFragment() { - if (mCurrentFragment instanceof MediaDetailFragment) { - return (MediaDetailFragment) mCurrentFragment; - } - - return null; - } - - /** - * Called to inform the adapter of which item is currently considered to be the "primary", - * that is the one show to the user as the current page. - * @param container - * @param position - * @param object - */ - @Override - public void setPrimaryItem(@NonNull final ViewGroup container, final int position, - @NonNull final Object object) { - // Update the current fragment if changed - if(getCurrentFragment() != object) { - mCurrentFragment = ((Fragment)object); - } - super.setPrimaryItem(container, position, object); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.kt new file mode 100644 index 000000000..b66c888aa --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.kt @@ -0,0 +1,622 @@ +package fr.free.nrw.commons.media + +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +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.ProgressBar +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import com.google.android.material.snackbar.Snackbar +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.R +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.bookmarks.models.Bookmark +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider +import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao +import fr.free.nrw.commons.contributions.Contribution +import fr.free.nrw.commons.contributions.MainActivity +import fr.free.nrw.commons.databinding.FragmentMediaDetailPagerBinding +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.profile.ProfileActivity.Companion.startYourself +import fr.free.nrw.commons.utils.ClipboardUtils.copy +import fr.free.nrw.commons.utils.DownloadUtils.downloadMedia +import fr.free.nrw.commons.utils.ImageUtils.setAvatarFromImageUrl +import fr.free.nrw.commons.utils.ImageUtils.setWallpaperFromImageUrl +import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished +import fr.free.nrw.commons.utils.ViewUtil.showShortSnackbar +import fr.free.nrw.commons.utils.handleWebUrl +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.net.URL +import java.util.concurrent.Callable +import javax.inject.Inject +import androidx.core.net.toUri + +class MediaDetailPagerFragment : CommonsDaggerSupportFragment(), OnPageChangeListener, + MediaDetailFragment.Callback { + @JvmField + @Inject + var bookmarkDao: BookmarkPicturesDao? = null + + @JvmField + @Inject + var okHttpJsonApiClient: OkHttpJsonApiClient? = null + + @JvmField + @Inject + var sessionManager: SessionManager? = null + + var binding: FragmentMediaDetailPagerBinding? = null + var editable: Boolean = false + var isFeaturedImage: Boolean = false + var isWikipediaButtonDisplayed: Boolean = false + var adapter: MediaDetailAdapter? = null + var bookmark: Bookmark? = null + var mediaDetailProvider: MediaDetailProvider? = null + var isFromFeaturedRootFragment: Boolean = false + var position: Int = 0 + + /** + * ProgressBar used to indicate the loading status of media items. + */ + var imageProgressBar: ProgressBar? = null + + var removedItems: ArrayList = ArrayList() + + fun clearRemoved() = removedItems.clear() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentMediaDetailPagerBinding.inflate(inflater, container, false) + binding!!.mediaDetailsPager.addOnPageChangeListener(this) + // Initialize the ProgressBar by finding it in the layout + imageProgressBar = binding!!.root.findViewById(R.id.itemProgressBar) + adapter = MediaDetailAdapter(this, childFragmentManager) + + // ActionBar is now supported in both activities - if this crashes something is quite wrong + val actionBar = (activity as AppCompatActivity).supportActionBar + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + } else { + throw AssertionError("Action bar should not be null!") + } + + // If fragment is associated with ProfileActivity, then hide the tabLayout + if (activity is ProfileActivity) { + (activity as ProfileActivity).setTabLayoutVisibility(false) + } else if (activity is MainActivity) { + (activity as MainActivity).hideTabs() + } + + binding!!.mediaDetailsPager.adapter = adapter + + if (savedInstanceState != null) { + val pageNumber = savedInstanceState.getInt("current-page") + binding!!.mediaDetailsPager.setCurrentItem(pageNumber, false) + requireActivity().invalidateOptionsMenu() + } + adapter!!.notifyDataSetChanged() + + return binding!!.root + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("current-page", binding!!.mediaDetailsPager.currentItem) + outState.putBoolean("editable", editable) + outState.putBoolean("isFeaturedImage", isFeaturedImage) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState != null) { + editable = savedInstanceState.getBoolean("editable", false) + isFeaturedImage = savedInstanceState.getBoolean("isFeaturedImage", false) + } + setHasOptionsMenu(true) + initProvider() + } + + /** + * initialise the provider, based on from where the fragment was started, as in from an activity + * or a fragment + */ + private fun initProvider() { + if (parentFragment is MediaDetailProvider) { + mediaDetailProvider = parentFragment as MediaDetailProvider + } else if (activity is MediaDetailProvider) { + mediaDetailProvider = activity as MediaDetailProvider? + } else { + throw ClassCastException("Parent must implement MediaDetailProvider") + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (activity == null) { + Timber.d("Returning as activity is destroyed!") + return true + } + + val m = mediaDetailProvider!!.getMediaAtPosition(binding!!.mediaDetailsPager.currentItem) + val mediaDetailFragment = adapter!!.currentMediaDetailFragment + when (item.itemId) { + R.id.menu_bookmark_current_image -> { + val bookmarkExists = bookmarkDao!!.updateBookmark(bookmark) + val snackbar = if (bookmarkExists) Snackbar.make( + requireView(), + R.string.add_bookmark, + Snackbar.LENGTH_LONG + ) else Snackbar.make( + requireView(), R.string.remove_bookmark, Snackbar.LENGTH_LONG + ) + snackbar.show() + updateBookmarkState(item) + return true + } + + R.id.menu_copy_link -> { + val uri = m!!.pageTitle.canonicalUri + copy("shareLink", uri, requireContext()) + Timber.d("Copied share link to clipboard: %s", uri) + Toast.makeText( + requireContext(), getString(R.string.menu_link_copied), + Toast.LENGTH_SHORT + ).show() + return true + } + + R.id.menu_share_current_image -> { + val shareIntent = Intent(Intent.ACTION_SEND) + shareIntent.setType("text/plain") + shareIntent.putExtra( + Intent.EXTRA_TEXT, """${m!!.displayTitle} +${m.pageTitle.canonicalUri}""" + ) + startActivity(Intent.createChooser(shareIntent, "Share image via...")) + + //Add media detail to backstack when the share button is clicked + //So that when the share is cancelled or completed the media detail page is on top + // of back stack fixing:https://github.com/commons-app/apps-android-commons/issues/2296 + val supportFragmentManager = requireActivity().supportFragmentManager + if (supportFragmentManager.backStackEntryCount < 2) { + supportFragmentManager + .beginTransaction() + .addToBackStack(MediaDetailPagerFragment::class.java.name) + .commit() + supportFragmentManager.executePendingTransactions() + } + return true + } + + R.id.menu_browser_current_image -> { + // View in browser + handleWebUrl(requireContext(), m!!.pageTitle.mobileUri.toUri()) + return true + } + + R.id.menu_download_current_image -> { + // Download + if (!isInternetConnectionEstablished(activity)) { + showShortSnackbar(requireView(), R.string.no_internet) + return false + } + downloadMedia(activity, m!!) + return true + } + + R.id.menu_set_as_wallpaper -> { + // Set wallpaper + setWallpaper(m!!) + return true + } + + R.id.menu_set_as_avatar -> { + // Set avatar + setAvatar(m!!) + return true + } + + R.id.menu_view_user_page -> { + if (m?.user != null) { + startYourself( + requireActivity(), m.user!!, + sessionManager!!.userName != m.user + ) + } + return true + } + + R.id.menu_view_report -> { + showReportDialog(m) + mediaDetailFragment?.onImageBackgroundChanged( + ContextCompat.getColor( + requireContext(), + R.color.white + ) + ) + return true + } + + R.id.menu_view_set_white_background -> { + mediaDetailFragment?.onImageBackgroundChanged( + ContextCompat.getColor( + requireContext(), + R.color.white + ) + ) + return true + } + + R.id.menu_view_set_black_background -> { + mediaDetailFragment?.onImageBackgroundChanged( + ContextCompat.getColor( + requireContext(), + R.color.black + ) + ) + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + private fun showReportDialog(media: Media?) { + if (media == null) { + return + } + val builder = AlertDialog.Builder(requireActivity()) + val values = requireContext().resources + .getStringArray(R.array.report_violation_options) + builder.setTitle(R.string.report_violation) + builder.setItems( + R.array.report_violation_options + ) { dialog: DialogInterface?, which: Int -> + sendReportEmail(media, values[which]) + } + builder.setNegativeButton( + R.string.cancel + ) { dialog: DialogInterface?, which: Int -> } + builder.setCancelable(false) + builder.show() + } + + private fun sendReportEmail(media: Media, type: String) { + val technicalInfo = getTechInfo(media, type) + + val feedbackIntent = Intent(Intent.ACTION_SENDTO) + feedbackIntent.setType("message/rfc822") + feedbackIntent.setData(Uri.parse("mailto:")) + feedbackIntent.putExtra( + Intent.EXTRA_EMAIL, + arrayOf(CommonsApplication.REPORT_EMAIL) + ) + feedbackIntent.putExtra( + Intent.EXTRA_SUBJECT, + CommonsApplication.REPORT_EMAIL_SUBJECT + ) + feedbackIntent.putExtra(Intent.EXTRA_TEXT, technicalInfo) + try { + startActivity(feedbackIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.no_email_client, Toast.LENGTH_SHORT).show() + } + } + + private fun getTechInfo(media: Media, type: String): String { + val builder = StringBuilder() + + builder.append("Report type: ") + .append(type) + .append("\n\n") + + builder.append("Image that you want to report: ") + .append(media.imageUrl) + .append("\n\n") + + builder.append("User that you want to report: ") + .append(media.user) + .append("\n\n") + + if (sessionManager!!.userName != null) { + builder.append("Your username: ") + .append(sessionManager!!.userName) + .append("\n\n") + } + + builder.append("Violation reason: ") + .append("\n") + + builder.append("----------------------------------------------") + .append("\n") + .append("(please write reason here)") + .append("\n") + .append("----------------------------------------------") + .append("\n\n") + .append("Thank you for your report! Our team will investigate as soon as possible.") + .append("\n") + .append("Please note that images also have a `Nominate for deletion` button.") + + return builder.toString() + } + + /** + * Set the media as the device's wallpaper if the imageUrl is not null + * Fails silently if setting the wallpaper fails + * @param media + */ + private fun setWallpaper(media: Media) { + if (media.imageUrl == null || media.imageUrl!!.isEmpty()) { + Timber.d("Media URL not present") + return + } + setWallpaperFromImageUrl(requireActivity(), media.imageUrl!!.toUri()) + } + + /** + * Set the media as user's leaderboard avatar + * @param media + */ + private fun setAvatar(media: Media) { + if (media.imageUrl == null || media.imageUrl!!.isEmpty()) { + Timber.d("Media URL not present") + return + } + setAvatarFromImageUrl( + requireActivity(), media.imageUrl!!, + sessionManager!!.currentAccount!!.name, + okHttpJsonApiClient!!, Companion.compositeDisposable + ) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (!editable) { // Disable menu options for editable views + menu.clear() // see http://stackoverflow.com/a/8495697/17865 + inflater.inflate(R.menu.fragment_image_detail, menu) + if (binding!!.mediaDetailsPager != null) { + val provider = mediaDetailProvider ?: return + val position = if (isFromFeaturedRootFragment) { + position + } else { + binding!!.mediaDetailsPager.currentItem + } + + val m = provider.getMediaAtPosition(position) + if (m != null) { + // Enable default set of actions, then re-enable different set of actions only if it is a failed contrib + menu.findItem(R.id.menu_browser_current_image).setEnabled(true).setVisible(true) + menu.findItem(R.id.menu_copy_link).setEnabled(true).setVisible(true) + menu.findItem(R.id.menu_share_current_image).setEnabled(true).setVisible(true) + menu.findItem(R.id.menu_download_current_image).setEnabled(true) + .setVisible(true) + menu.findItem(R.id.menu_bookmark_current_image).setEnabled(true) + .setVisible(true) + menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(true).setVisible(true) + if (m.user != null) { + menu.findItem(R.id.menu_view_user_page).setEnabled(true).setVisible(true) + } + + try { + val mediaUrl = URL(m.imageUrl) + handleBackgroundColorMenuItems({ + BitmapFactory.decodeStream( + mediaUrl.openConnection().getInputStream() + ) + }, menu) + } catch (e: Exception) { + Timber.e("Cant detect media transparency") + } + + // Initialize bookmark object + bookmark = Bookmark( + m.filename, + m.getAuthorOrUser(), + BookmarkPicturesContentProvider.uriForName(m.filename) + ) + updateBookmarkState(menu.findItem(R.id.menu_bookmark_current_image)) + val contributionState = provider.getContributionStateAt(position) + if (contributionState != null) { + when (contributionState) { + Contribution.STATE_FAILED, Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED -> { + menu.findItem(R.id.menu_browser_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_copy_link).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_share_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_download_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_bookmark_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(false) + .setVisible(false) + } + + Contribution.STATE_COMPLETED -> {} + } + } + } else { + menu.findItem(R.id.menu_browser_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_copy_link).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_share_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_download_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_bookmark_current_image).setEnabled(false) + .setVisible(false) + menu.findItem(R.id.menu_set_as_wallpaper).setEnabled(false) + .setVisible(false) + } + + if (!sessionManager!!.isUserLoggedIn) { + menu.findItem(R.id.menu_set_as_avatar).setVisible(false) + } + } + } + } + + /** + * Decide wether or not we should display the background color menu items + * We display them if the image is transparent + * @param getBitmap + * @param menu + */ + private fun handleBackgroundColorMenuItems(getBitmap: Callable, menu: Menu) { + Observable.fromCallable( + getBitmap + ).subscribeOn(Schedulers.newThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer { image: Bitmap -> + if (image.hasAlpha()) { + menu.findItem(R.id.menu_view_set_white_background).setVisible(true) + .setEnabled(true) + menu.findItem(R.id.menu_view_set_black_background).setVisible(true) + .setEnabled(true) + } + }) + } + + private fun updateBookmarkState(item: MenuItem) { + val isBookmarked = bookmarkDao!!.findBookmark(bookmark) + if (isBookmarked) { + if (removedItems.contains(binding!!.mediaDetailsPager.currentItem)) { + removedItems.remove(binding!!.mediaDetailsPager.currentItem) + } + } else { + if (!removedItems.contains(binding!!.mediaDetailsPager.currentItem)) { + removedItems.add(binding!!.mediaDetailsPager.currentItem) + } + } + + item.setIcon(if (isBookmarked) { + R.drawable.menu_ic_round_star_filled_24px + } else { + R.drawable.menu_ic_round_star_border_24px + }) + } + + fun showImage(i: Int, isWikipediaButtonDisplayed: Boolean) { + this.isWikipediaButtonDisplayed = isWikipediaButtonDisplayed + setViewPagerCurrentItem(i) + } + + fun showImage(i: Int) { + setViewPagerCurrentItem(i) + } + + /** + * This function waits for the item to load then sets the item to current item + * @param position current item that to be shown + */ + private fun setViewPagerCurrentItem(position: Int) { + val handler = Handler(Looper.getMainLooper()) + val runnable: Runnable = object : Runnable { + override fun run() { + // Show the ProgressBar while waiting for the item to load + imageProgressBar!!.visibility = View.VISIBLE + // Check if the adapter has enough items loaded + if (adapter!!.count > position) { + // Set the current item in the ViewPager + binding!!.mediaDetailsPager.setCurrentItem(position, false) + // Hide the ProgressBar once the item is loaded + imageProgressBar!!.visibility = View.GONE + } else { + // If the item is not ready yet, post the Runnable again + handler.post(this) + } + } + } + // Start the Runnable + handler.post(runnable) + } + + /** + * The method notify the viewpager that number of items have changed. + */ + fun notifyDataSetChanged() { + if (null != adapter) { + adapter!!.notifyDataSetChanged() + } + } + + override fun onPageScrolled(i: Int, v: Float, i2: Int) { + if (activity == null) { + Timber.d("Returning as activity is destroyed!") + return + } + + requireActivity().invalidateOptionsMenu() + } + + override fun onPageSelected(i: Int) { + } + + override fun onPageScrollStateChanged(i: Int) { + } + + fun onDataSetChanged() { + if (null != adapter) { + adapter!!.notifyDataSetChanged() + } + } + + /** + * Called after the media is nominated for deletion + * + * @param index item position that has been nominated + */ + override fun nominatingForDeletion(index: Int) { + mediaDetailProvider!!.refreshNominatedMedia(index) + } + + companion object { + private val compositeDisposable = CompositeDisposable() + + /** + * Use this factory method to create a new instance of this fragment using the provided + * parameters. + * + * This method will create a new instance of MediaDetailPagerFragment and the arguments will be + * saved to a bundle which will be later available in the [.onCreate] + * @param editable + * @param isFeaturedImage + * @return + */ + @JvmStatic + fun newInstance(editable: Boolean, isFeaturedImage: Boolean): MediaDetailPagerFragment { + val mediaDetailPagerFragment = MediaDetailPagerFragment() + val args = Bundle() + args.putBoolean("is_editable", editable) + args.putBoolean("is_featured_image", isFeaturedImage) + mediaDetailPagerFragment.arguments = args + return mediaDetailPagerFragment + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailProvider.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailProvider.kt new file mode 100644 index 000000000..591adfe75 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailProvider.kt @@ -0,0 +1,14 @@ +package fr.free.nrw.commons.media + +import fr.free.nrw.commons.Media + +interface MediaDetailProvider { + fun getMediaAtPosition(i: Int): Media? + + fun getTotalMediaCount(): Int + + fun getContributionStateAt(position: Int): Int? + + // Reload media detail fragment once media is nominated + fun refreshNominatedMedia(index: Int) +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.java b/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.java deleted file mode 100644 index 28df3811a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package fr.free.nrw.commons.media; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import fr.free.nrw.commons.wikidata.mwapi.MwResponse; - -public class MwParseResponse extends MwResponse { - @Nullable - private MwParseResult parse; - - @Nullable - public MwParseResult parse() { - return parse; - } - - public boolean success() { - return parse != null; - } - - @VisibleForTesting - protected void setParse(@Nullable MwParseResult parse) { - this.parse = parse; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.kt b/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.kt new file mode 100644 index 000000000..fc0282a9e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MwParseResponse.kt @@ -0,0 +1,17 @@ +package fr.free.nrw.commons.media + +import androidx.annotation.VisibleForTesting +import fr.free.nrw.commons.wikidata.mwapi.MwResponse + +class MwParseResponse : MwResponse() { + private var parse: MwParseResult? = null + + fun parse(): MwParseResult? = parse + + fun success(): Boolean = parse != null + + @VisibleForTesting + protected fun setParse(parse: MwParseResult?) { + this.parse = parse + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.java b/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.java deleted file mode 100644 index edb7ff447..000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package fr.free.nrw.commons.media; - -import com.google.gson.annotations.SerializedName; - -public class MwParseResult { - @SuppressWarnings("unused") private int pageid; - @SuppressWarnings("unused") private int index; - private MwParseText text; - - public String text() { - return text.text; - } - - - public class MwParseText{ - @SerializedName("*") private String text; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.kt b/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.kt new file mode 100644 index 000000000..7aacdea09 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MwParseResult.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.media + +import com.google.gson.annotations.SerializedName + +class MwParseResult { + private val pageid = 0 + private val index = 0 + private val text: MwParseText? = null + + fun text(): String? { + return text?.text + } + + inner class MwParseText { + @SerializedName("*") + internal val text: String? = null + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt index a0dcead07..5c991f465 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/fragments/NearbyParentFragment.kt @@ -74,6 +74,7 @@ import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType import fr.free.nrw.commons.location.LocationUpdateListener import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaDetailPagerFragment +import fr.free.nrw.commons.media.MediaDetailProvider import fr.free.nrw.commons.navtab.NavTab import fr.free.nrw.commons.nearby.BottomSheetAdapter import fr.free.nrw.commons.nearby.BottomSheetAdapter.ItemClickListener @@ -150,7 +151,7 @@ class NearbyParentFragment : CommonsDaggerSupportFragment(), LocationUpdateListener, LocationPermissionCallback, ItemClickListener, - MediaDetailPagerFragment.MediaDetailProvider { + MediaDetailProvider { var binding: FragmentNearbyParentBinding? = null val mapEventsOverlay: MapEventsOverlay = MapEventsOverlay(object : MapEventsReceiver { diff --git a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/BookmarkListRootFragmentUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/BookmarkListRootFragmentUnitTest.kt index 5098bd0c1..d168fe6e4 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/BookmarkListRootFragmentUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/bookmarks/BookmarkListRootFragmentUnitTest.kt @@ -235,7 +235,7 @@ class BookmarkListRootFragmentUnitTest { @Throws(Exception::class) fun testGetTotalMediaCountCaseNull() { whenever(bookmarksPagerAdapter.mediaAdapter).thenReturn(null) - Assert.assertEquals(fragment.totalMediaCount, 0) + Assert.assertEquals(fragment.getTotalMediaCount(), 0) } @Test @@ -244,7 +244,7 @@ class BookmarkListRootFragmentUnitTest { val listAdapter = mock(ListAdapter::class.java) whenever(bookmarksPagerAdapter.mediaAdapter).thenReturn(listAdapter) whenever(listAdapter.count).thenReturn(1) - Assert.assertEquals(fragment.totalMediaCount, 1) + Assert.assertEquals(fragment.getTotalMediaCount(), 1) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDetailsActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDetailsActivityUnitTests.kt index fd6bc7976..b1f40a08f 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDetailsActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/category/CategoryDetailsActivityUnitTests.kt @@ -76,7 +76,7 @@ class CategoryDetailsActivityUnitTests { @Test @Throws(Exception::class) fun testGetTotalMediaCount() { - activity.totalMediaCount + activity.getTotalMediaCount() } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsFragmentUnitTests.kt index e3f1c86cc..16848f7dd 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/contributions/ContributionsFragmentUnitTests.kt @@ -334,7 +334,7 @@ class ContributionsFragmentUnitTests { @Throws(Exception::class) fun testGetTotalMediaCount() { Shadows.shadowOf(Looper.getMainLooper()).idle() - fragment.totalMediaCount + fragment.getTotalMediaCount() } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreListRootFragmentUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreListRootFragmentUnitTest.kt index 9dc94293f..62068f4f9 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreListRootFragmentUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/ExploreListRootFragmentUnitTest.kt @@ -189,8 +189,8 @@ class ExploreListRootFragmentUnitTest { @Test @Throws(Exception::class) fun testGetTotalMediaCount() { - `when`(listFragment.totalMediaCount).thenReturn(1) - Assert.assertEquals(fragment.totalMediaCount, 1) + `when`(listFragment.getTotalMediaCount()).thenReturn(1) + Assert.assertEquals(fragment.getTotalMediaCount(), 1) } @Test @@ -199,7 +199,7 @@ class ExploreListRootFragmentUnitTest { val field: Field = ExploreListRootFragment::class.java.getDeclaredField("listFragment") field.isAccessible = true field.set(fragment, null) - Assert.assertEquals(fragment.totalMediaCount, 0) + Assert.assertEquals(fragment.getTotalMediaCount(), 0) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivityUnitTests.kt index bf5aca6e2..8ba2e86a8 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/depictions/WikidataItemDetailsActivityUnitTests.kt @@ -108,7 +108,7 @@ class WikidataItemDetailsActivityUnitTests { @Test @Throws(Exception::class) fun testGetTotalMediaCount() { - activity.totalMediaCount + activity.getTotalMediaCount() } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/explore/search/SearchActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/explore/search/SearchActivityUnitTests.kt index 751046e7f..00b9c0fd9 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/explore/search/SearchActivityUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/explore/search/SearchActivityUnitTests.kt @@ -160,8 +160,8 @@ class SearchActivityUnitTests { fun testGetTotalMediaCount() { val num = 1 Whitebox.setInternalState(activity, "searchMediaFragment", searchMediaFragment) - `when`(searchMediaFragment.totalMediaCount).thenReturn(num) - assertEquals(activity.totalMediaCount, num) + `when`(searchMediaFragment.getTotalMediaCount()).thenReturn(num) + assertEquals(activity.getTotalMediaCount(), num) } @Test diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcherUnitTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcherUnitTest.kt index 16a35a67b..225b4bd80 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcherUnitTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/CustomOkHttpNetworkFetcherUnitTest.kt @@ -32,7 +32,7 @@ import java.util.concurrent.Executor class CustomOkHttpNetworkFetcherUnitTest { private lateinit var fetcher: CustomOkHttpNetworkFetcher private lateinit var okHttpClient: OkHttpClient - private lateinit var state: CustomOkHttpNetworkFetcher.OkHttpNetworkFetchState + private lateinit var state: OkHttpNetworkFetchState @Mock private lateinit var callback: NetworkFetcher.Callback @@ -162,7 +162,7 @@ class CustomOkHttpNetworkFetcherUnitTest { val method: Method = CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( "onFetchResponse", - CustomOkHttpNetworkFetcher.OkHttpNetworkFetchState::class.java, + OkHttpNetworkFetchState::class.java, Call::class.java, Response::class.java, NetworkFetcher.Callback::class.java, @@ -196,7 +196,7 @@ class CustomOkHttpNetworkFetcherUnitTest { val method: Method = CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( "onFetchResponse", - CustomOkHttpNetworkFetcher.OkHttpNetworkFetchState::class.java, + OkHttpNetworkFetchState::class.java, Call::class.java, Response::class.java, NetworkFetcher.Callback::class.java, @@ -230,7 +230,7 @@ class CustomOkHttpNetworkFetcherUnitTest { val method: Method = CustomOkHttpNetworkFetcher::class.java.getDeclaredMethod( "onFetchResponse", - CustomOkHttpNetworkFetcher.OkHttpNetworkFetchState::class.java, + OkHttpNetworkFetchState::class.java, Call::class.java, Response::class.java, NetworkFetcher.Callback::class.java, diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt index b6d3c6e28..6159a3ccf 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/media/MediaDetailFragmentUnitTests.kt @@ -132,7 +132,7 @@ class MediaDetailFragmentUnitTests { private lateinit var button: Button @Mock - private lateinit var detailProvider: MediaDetailPagerFragment.MediaDetailProvider + private lateinit var detailProvider: MediaDetailProvider @Mock private lateinit var applicationKvStore: JsonKvStore From a4c7a9c4f7cb4fcfef359ba015f59ac13e36dd0e Mon Sep 17 00:00:00 2001 From: Ritika Pahwa <83745993+RitikaPahwa4444@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:56:35 +0530 Subject: [PATCH 2/5] Fix java.lang.SecurityException for ACTION_OPEN_DOCUMENT (#6370) --- .../free/nrw/commons/filepicker/FilePicker.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 ccccfbd34..bb0a371e1 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 @@ -256,7 +256,9 @@ object FilePicker : Constants { * */ val intent = if (openDocumentIntentPreferred) { - Intent(Intent.ACTION_OPEN_DOCUMENT) + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + } } else { Intent(Intent.ACTION_GET_CONTENT) } @@ -271,6 +273,7 @@ object FilePicker : Constants { callbacks: Callbacks ) { if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + takePersistableUriPermissions(activity, result) try { val files = getFilesFromGalleryPictures(result.data, activity) callbacks.onImagesPicked(files, ImageSource.DOCUMENTS, restoreType(activity)) @@ -283,6 +286,23 @@ object FilePicker : Constants { } } + /** + * takePersistableUriPermission is necessary to persist the URI permission as + * the permission granted by the system for read or write access on ACTION_OPEN_DOCUMENT + * lasts only until the user's device restarts. + * Ref: https://developer.android.com/training/data-storage/shared/documents-files#persist-permissions + * + * This helps fix the SecurityException reported in this issue: + * https://github.com/commons-app/apps-android-commons/issues/6357 + */ + private fun takePersistableUriPermissions(context: Context, result: ActivityResult) { + result.data?.data?.also { uri -> + val takeFlags: Int = (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + } + } + /** * onPictureReturnedFromCustomSelector. * Retrieve and forward the images to upload wizard through callback. From 7500b6d3744623f58b89e395df02dc912e097b57 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Mon, 14 Jul 2025 14:01:46 +0200 Subject: [PATCH 3/5] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-ce/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-gl/strings.xml | 9 +- app/src/main/res/values-ka/strings.xml | 167 ++++++++++++++++++++++++- app/src/main/res/values-pl/strings.xml | 5 +- app/src/main/res/values-ps/strings.xml | 4 +- 6 files changed, 176 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/values-ce/strings.xml b/app/src/main/res/values-ce/strings.xml index a009bd39d..3855a80cd 100644 --- a/app/src/main/res/values-ce/strings.xml +++ b/app/src/main/res/values-ce/strings.xml @@ -114,7 +114,7 @@ Ахь шинафакторийн аутентификацин код йазо йеза Системин довзийтарца гӀалат! Чуйолуш йу - ДӀайазйе хӀокху файлийн тобан цӀе + ДӀайазйе хӀокху файлийн тобанан цӀе Хийцамаш Чуйолуш йу Категори харжар diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index daad36b83..457f7257c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,6 +6,7 @@ * Assorted-Interests * BaRaN6161 TURK * Bananax47 +* Billibilbi * BlueCamille * Cigaryno * Cyclicus @@ -50,7 +51,7 @@ --> Page Facebook de Commons - Code source Github de Commons + Code source de Commons sur Github Logo de Commons Site web de Commons Sélecteur d\'emplacement de sortie diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index bac417647..f2a606ab9 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -232,8 +232,9 @@ Acerca de Configuración Comentarios + Comentarios a través de GitHub Saír - Titorial + Guía Notificacións Revisar non se atopou descrición @@ -277,7 +278,7 @@ Avalíenos FAQ Guía de uso - Saltar titorial + Saltar a guía Internet non dispoñible Erro ó recuperar as notificacións Houbo un erro ó recuperar a imaxe a revisar. Prema en refrescar para tentalo de novo. @@ -319,7 +320,7 @@ Pregunta Resultado Se continúa cargando imaxes que requiran ser eliminadas, a súa conta probablemente sexa bloqueada. Está seguro de que quere rematar o cuestionario? - Máis do %1$s das imaxes que cargou foron eliminadas. Se continúa cargando imaxes que requiran ser borradas, probablemente a súa conta sexa bloqueada.\n\nGustaríalle ver de novo o titorial e facer un pequeno cuestionario que axuda a entender que tipo de imaxes se deben ou non se deben cargar? + Máis do %1$s das imaxes que cargou foron eliminadas. Se continúa cargando imaxes que requiran ser borradas, probablemente a súa conta sexa bloqueada.\n\nGustaríalle ver de novo a guía e facer un pequeno cuestionario que axuda a entender que tipo de imaxes se deben ou non se deben cargar? Os autorretratos non teñen valor enciclopédico abondo. Por favor no cargue imaxes de vostede mesmo salvo que haxa un artigo de Wikipedia sobre vostede. As fotografías de monumentos e paisaxes de exterior poden ser cargadas na maioría dos países. Teña en conta, por favor, que as instalacións de arte temporais en exteriores normalmente teñen dereitos de autor protexidos e non poden ser cargadas. As capturas de pantalla de sitios web son consideradas traballos derivados e están suxeitas a dereitos de autor. Poden ser usadas despois de obter a autorización do autor. Sen ese permiso, calquera obra que cree baseada no seu traballo está considerada legalmente como unha copia sen licenza, cuxa propiedade é mantida polo autor orixinal. @@ -394,7 +395,7 @@ Decateime de que prexudica a miña privacidade Cambiei de idea, non quero que siga sendo visible de forma pública Desculpas, esta imaxe non é interesante para unha enciclopedia - Cargada por min o %1$s, usada en %2$d artigo(s). + Cargada por min o %1$s, usada polo menos en %2$d artigo(s). Dámoslle a benvida ó Commonsǃ\n\nCargue o seu primeiro ficheiro premendo no botón Engadir. Non hai categorías seleccionadas As imaxes sen categorías só son utilizables en contadas ocasións. Está seguro de que quere continuar sen seleccionar categorías? diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index e560298dd..542b8895a 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -1,5 +1,6 @@