diff --git a/app/build.gradle b/app/build.gradle index b83f2b01ba..a16fe60af9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -52,13 +52,14 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' // Jetpack Compose - def composeBom = platform('androidx.compose:compose-bom:2024.08.00') + def composeBom = platform('androidx.compose:compose-bom:2024.11.00') - implementation "androidx.activity:activity-compose:1.9.1" + implementation "androidx.activity:activity-compose:1.9.3" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" implementation (composeBom) implementation "androidx.compose.runtime:runtime" implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-viewbinding" implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-tooling" implementation "androidx.compose.foundation:foundation" @@ -313,6 +314,7 @@ android { buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" @@ -348,6 +350,7 @@ android { buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" + buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt new file mode 100644 index 0000000000..96d19d1cff --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesResponse.kt @@ -0,0 +1,35 @@ +package fr.free.nrw.commons.fileusages + +import com.google.gson.annotations.SerializedName + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class FileUsagesResponse( + @SerializedName("continue") val continueResponse: CommonsContinue?, + @SerializedName("batchcomplete") val batchComplete: Boolean, + @SerializedName("query") val query: Query, +) + +data class CommonsContinue( + @SerializedName("fucontinue") val fuContinue: String, + @SerializedName("continue") val continueKey: String +) + +data class Query( + @SerializedName("pages") val pages: List +) + +data class Page( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("fileusage") val fileUsage: List +) + +data class FileUsage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("redirect") val redirect: Boolean +) diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt new file mode 100644 index 0000000000..63b0740d02 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/FileUsagesUiModel.kt @@ -0,0 +1,18 @@ +package fr.free.nrw.commons.fileusages + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class FileUsagesUiModel( + val title: String, + val link: String? +) + +fun FileUsage.toUiModel(): FileUsagesUiModel { + return FileUsagesUiModel(title = title, link = "https://commons.wikimedia.org/wiki/$title") +} + +fun GlobalFileUsage.toUiModel(): FileUsagesUiModel { + // link is associated with sub items under wiki group (which is not used ATM) + return FileUsagesUiModel(title = wiki, link = null) +} diff --git a/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt new file mode 100644 index 0000000000..17580539ed --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/fileusages/GlobalFileUsagesResponse.kt @@ -0,0 +1,34 @@ +package fr.free.nrw.commons.fileusages + +import com.google.gson.annotations.SerializedName + +/** + * Show where file is being used on Commons and oher wikis. + */ +data class GlobalFileUsagesResponse( + @SerializedName("continue") val continueResponse: GlobalContinue?, + @SerializedName("batchcomplete") val batchComplete: Boolean, + @SerializedName("query") val query: GlobalQuery, +) + +data class GlobalContinue( + @SerializedName("gucontinue") val guContinue: String, + @SerializedName("continue") val continueKey: String +) + +data class GlobalQuery( + @SerializedName("pages") val pages: List +) + +data class GlobalPage( + @SerializedName("pageid") val pageId: Int, + @SerializedName("ns") val nameSpace: Int, + @SerializedName("title") val title: String, + @SerializedName("globalusage") val fileUsage: List +) + +data class GlobalFileUsage( + @SerializedName("title") val title: String, + @SerializedName("wiki") val wiki: String, + @SerializedName("url") val url: String +) diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java deleted file mode 100644 index 66f2221b82..0000000000 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.java +++ /dev/null @@ -1,1466 +0,0 @@ -package fr.free.nrw.commons.media; - -import static android.view.View.GONE; -import static android.view.View.VISIBLE; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_NEEDING_CATEGORIES; -import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_UNCATEGORISED; -import static fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION; -import static fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT; -import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION; -import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources; - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.drawable.Animatable; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.View; -import android.view.View.OnKeyListener; -import android.view.ViewGroup; -import android.view.ViewTreeObserver; -import android.view.ViewTreeObserver.OnGlobalLayoutListener; -import android.widget.ArrayAdapter; -import android.widget.Button; -import android.widget.EditText; -import android.widget.LinearLayout; -import android.widget.Spinner; -import android.widget.TextView; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentTransaction; -import com.facebook.drawee.backends.pipeline.Fresco; -import com.facebook.drawee.controller.BaseControllerListener; -import com.facebook.drawee.controller.ControllerListener; -import com.facebook.drawee.interfaces.DraweeController; -import com.facebook.imagepipeline.image.ImageInfo; -import com.facebook.imagepipeline.request.ImageRequest; -import fr.free.nrw.commons.BuildConfig; -import fr.free.nrw.commons.CameraPosition; -import fr.free.nrw.commons.CommonsApplication; -import fr.free.nrw.commons.LocationPicker.LocationPicker; -import fr.free.nrw.commons.Media; -import fr.free.nrw.commons.MediaDataExtractor; -import fr.free.nrw.commons.R; -import fr.free.nrw.commons.Utils; -import fr.free.nrw.commons.actions.ThanksClient; -import fr.free.nrw.commons.auth.AccountUtilKt; -import fr.free.nrw.commons.auth.SessionManager; -import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException; -import fr.free.nrw.commons.category.CategoryClient; -import fr.free.nrw.commons.category.CategoryDetailsActivity; -import fr.free.nrw.commons.category.CategoryEditHelper; -import fr.free.nrw.commons.contributions.ContributionsFragment; -import fr.free.nrw.commons.coordinates.CoordinateEditHelper; -import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding; -import fr.free.nrw.commons.delete.DeleteHelper; -import fr.free.nrw.commons.delete.ReasonBuilder; -import fr.free.nrw.commons.description.DescriptionEditActivity; -import fr.free.nrw.commons.description.DescriptionEditHelper; -import fr.free.nrw.commons.di.CommonsDaggerSupportFragment; -import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity; -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.profile.ProfileActivity; -import fr.free.nrw.commons.review.ReviewHelper; -import fr.free.nrw.commons.settings.Prefs; -import fr.free.nrw.commons.upload.UploadMediaDetail; -import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment; -import fr.free.nrw.commons.upload.depicts.DepictsFragment; -import fr.free.nrw.commons.utils.DateUtil; -import fr.free.nrw.commons.utils.DialogUtil; -import fr.free.nrw.commons.utils.PermissionUtils; -import fr.free.nrw.commons.utils.ViewUtil; -import fr.free.nrw.commons.utils.ViewUtilWrapper; -import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage; -import io.reactivex.Observable; -import io.reactivex.ObservableSource; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.schedulers.Schedulers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.Callable; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.inject.Inject; -import javax.inject.Named; -import org.apache.commons.lang3.StringUtils; -import timber.log.Timber; - -public class MediaDetailFragment extends CommonsDaggerSupportFragment implements - CategoryEditHelper.Callback { - - private static final String IMAGE_BACKGROUND_COLOR = "image_background_color"; - static final int DEFAULT_IMAGE_BACKGROUND_COLOR = 0; - - private boolean editable; - private boolean isCategoryImage; - private MediaDetailPagerFragment.MediaDetailProvider detailProvider; - private int index; - private boolean isDeleted = false; - private boolean isWikipediaButtonDisplayed; - private Callback callback; - - @Inject - LocationServiceManager locationManager; - - - public static MediaDetailFragment forMedia(int index, boolean editable, boolean isCategoryImage, boolean isWikipediaButtonDisplayed) { - MediaDetailFragment mf = new MediaDetailFragment(); - Bundle state = new Bundle(); - state.putBoolean("editable", editable); - state.putBoolean("isCategoryImage", isCategoryImage); - state.putInt("index", index); - state.putInt("listIndex", 0); - state.putInt("listTop", 0); - state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - mf.setArguments(state); - - return mf; - } - - @Inject - SessionManager sessionManager; - - @Inject - MediaDataExtractor mediaDataExtractor; - @Inject - ReasonBuilder reasonBuilder; - @Inject - DeleteHelper deleteHelper; - @Inject - ReviewHelper reviewHelper; - @Inject - CategoryEditHelper categoryEditHelper; - @Inject - CoordinateEditHelper coordinateEditHelper; - @Inject - DescriptionEditHelper descriptionEditHelper; - @Inject - ViewUtilWrapper viewUtil; - @Inject - CategoryClient categoryClient; - @Inject - ThanksClient thanksClient; - @Inject - @Named("default_preferences") - JsonKvStore applicationKvStore; - - private int initialListTop = 0; - private FragmentMediaDetailBinding binding; - String descriptionHtmlCode; - - - - - private ArrayList categoryNames = new ArrayList<>(); - private String categorySearchQuery; - - /** - * Depicts is a feature part of Structured data. Multiple Depictions can be added for an image just like categories. - * However unlike categories depictions is multi-lingual - * Ex: key: en value: monument - */ - private ImageInfo imageInfoCache; - private int oldWidthOfImageView; - private int newWidthOfImageView; - private boolean heightVerifyingBoolean = true; // helps in maintaining aspect ratio - private ViewTreeObserver.OnGlobalLayoutListener layoutListener; // for layout stuff, only used once! - - //Had to make this class variable, to implement various onClicks, which access the media, also I fell why make separate variables when one can serve the purpose - private Media media; - private ArrayList reasonList; - private ArrayList reasonListEnglishMappings; - - /** - * Height stores the height of the frame layout as soon as it is initialised and updates itself on - * configuration changes. - * Used to adjust aspect ratio of image when length of the image is too large. - */ - private int frameLayoutHeight; - - /** - * Minimum height of the metadata, in pixels. - * Images with a very narrow aspect ratio will be reduced so that the metadata information panel always has at least this height. - */ - private int minimumHeightOfMetadata = 200; - - final static String NOMINATING_FOR_DELETION_MEDIA = "Nominating for deletion %s"; - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - outState.putInt("index", index); - outState.putBoolean("editable", editable); - outState.putBoolean("isCategoryImage", isCategoryImage); - outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed); - - getScrollPosition(); - outState.putInt("listTop", initialListTop); - } - - private void getScrollPosition() { - initialListTop = binding.mediaDetailScrollView.getScrollY(); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (getParentFragment() != null - && getParentFragment() instanceof MediaDetailPagerFragment) { - detailProvider = - ((MediaDetailPagerFragment) getParentFragment()).getMediaDetailProvider(); - } - if (savedInstanceState != null) { - editable = savedInstanceState.getBoolean("editable"); - isCategoryImage = savedInstanceState.getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed"); - index = savedInstanceState.getInt("index"); - initialListTop = savedInstanceState.getInt("listTop"); - } else { - editable = getArguments().getBoolean("editable"); - isCategoryImage = getArguments().getBoolean("isCategoryImage"); - isWikipediaButtonDisplayed = getArguments().getBoolean("isWikipediaButtonDisplayed"); - index = getArguments().getInt("index"); - initialListTop = 0; - } - - reasonList = new ArrayList<>(); - reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonList.add(getString(R.string.deletion_reason_publicly_visible)); - reasonList.add(getString(R.string.deletion_reason_not_interesting)); - reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)); - reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)); - - // Add corresponding mappings in english locale so that we can upload it in deletion request - reasonListEnglishMappings = new ArrayList<>(); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_uploaded_by_mistake)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_publicly_visible)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_not_interesting)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_no_longer_want_public)); - reasonListEnglishMappings.add(getLocalizedResources(getContext(), Locale.ENGLISH).getString(R.string.deletion_reason_bad_for_my_privacy)); - - binding = FragmentMediaDetailBinding.inflate(inflater, container, false); - final View view = binding.getRoot(); - - - Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()); - - if (isCategoryImage){ - binding.authorLinearLayout.setVisibility(VISIBLE); - } else { - binding.authorLinearLayout.setVisibility(GONE); - } - - if (!sessionManager.isUserLoggedIn()) { - binding.categoryEditButton.setVisibility(GONE); - binding.descriptionEdit.setVisibility(GONE); - binding.depictionsEditButton.setVisibility(GONE); - } else { - binding.categoryEditButton.setVisibility(VISIBLE); - binding.descriptionEdit.setVisibility(VISIBLE); - binding.depictionsEditButton.setVisibility(VISIBLE); - } - - if(applicationKvStore.getBoolean("login_skipped")){ - binding.nominateDeletion.setVisibility(GONE); - binding.coordinateEdit.setVisibility(GONE); - } - - handleBackEvent(view); - - //set onCLick listeners - binding.mediaDetailLicense.setOnClickListener(v -> onMediaDetailLicenceClicked()); - binding.mediaDetailCoordinates.setOnClickListener(v -> onMediaDetailCoordinatesClicked()); - binding.sendThanks.setOnClickListener(v -> sendThanksToAuthor()); - binding.dummyCaptionDescriptionContainer.setOnClickListener(v -> showCaptionAndDescription()); - binding.mediaDetailImageView.setOnClickListener(v -> launchZoomActivity(binding.mediaDetailImageView)); - binding.categoryEditButton.setOnClickListener(v -> onCategoryEditButtonClicked()); - binding.depictionsEditButton.setOnClickListener(v -> onDepictionsEditButtonClicked()); - binding.seeMore.setOnClickListener(v -> onSeeMoreClicked()); - binding.mediaDetailAuthor.setOnClickListener(v -> onAuthorViewClicked()); - binding.nominateDeletion.setOnClickListener(v -> onDeleteButtonClicked()); - binding.descriptionEdit.setOnClickListener(v -> onDescriptionEditClicked()); - binding.coordinateEdit.setOnClickListener(v -> onUpdateCoordinatesClicked()); - binding.copyWikicode.setOnClickListener(v -> onCopyWikicodeClicked()); - - - /** - * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio - * of the picture. - */ - view.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - - return view; - } - - public void launchZoomActivity(final View view) { - final boolean hasPermission = PermissionUtils.hasPermission(getActivity(), PermissionUtils.getPERMISSIONS_STORAGE()); - if (hasPermission) { - launchZoomActivityAfterPermissionCheck(view); - } else { - PermissionUtils.checkPermissionsAndPerformAction(getActivity(), - () -> { - launchZoomActivityAfterPermissionCheck(view); - }, - R.string.storage_permission_title, - R.string.read_storage_permission_rationale, - PermissionUtils.getPERMISSIONS_STORAGE() - ); - } - } - - /** - * launch zoom acitivity after permission check - * @param view as ImageView - */ - private void launchZoomActivityAfterPermissionCheck(final View view) { - if (media.getImageUrl() != null) { - final Context ctx = view.getContext(); - final Intent zoomableIntent = new Intent(ctx, ZoomableActivity.class); - zoomableIntent.setData(Uri.parse(media.getImageUrl())); - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails"); - - int backgroundColor = getImageBackgroundColor(); - if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - zoomableIntent.putExtra( - ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, - backgroundColor - ); - } - - ctx.startActivity( - zoomableIntent - ); - } - } - - @Override - public void onResume() { - super.onResume(); - if (getParentFragment() != null && getParentFragment().getParentFragment() != null) { - //Added a check because, not necessarily, the parent fragment will have a parent fragment, say - // in the case when MediaDetailPagerFragment is directly started by the CategoryImagesActivity - if (getParentFragment() instanceof ContributionsFragment) { - ((ContributionsFragment) (getParentFragment() - .getParentFragment())).binding.cardViewNearby - .setVisibility(View.GONE); - } - } - // detail provider is null when fragment is shown in review activity - if (detailProvider != null) { - media = detailProvider.getMediaAtPosition(index); - } else { - media = getArguments().getParcelable("media"); - } - - if(media != null && applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - enableProgressBar(); - } - - if (AccountUtilKt.getUserName(getContext()) != null && media != null - && AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - binding.sendThanks.setVisibility(GONE); - } else { - binding.sendThanks.setVisibility(VISIBLE); - } - - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - if (getContext() == null) { - return; - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - oldWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - if(media != null) { - displayMediaDetails(); - } - } - } - ); - binding.progressBarEdit.setVisibility(GONE); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - binding.mediaDetailScrollView.getViewTreeObserver().addOnGlobalLayoutListener( - new OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - /** - * We update the height of the frame layout as the configuration changes. - */ - binding.mediaDetailFrameLayout.post(new Runnable() { - @Override - public void run() { - frameLayoutHeight = binding.mediaDetailFrameLayout.getMeasuredHeight(); - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }); - if (binding.mediaDetailScrollView.getWidth() != oldWidthOfImageView) { - if (newWidthOfImageView == 0) { - newWidthOfImageView = binding.mediaDetailScrollView.getWidth(); - updateAspectRatio(newWidthOfImageView); - } - binding.mediaDetailScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - } - } - } - ); - // Ensuring correct aspect ratio for landscape mode - if (heightVerifyingBoolean) { - updateAspectRatio(newWidthOfImageView); - heightVerifyingBoolean = false; - } else { - updateAspectRatio(oldWidthOfImageView); - heightVerifyingBoolean = true; - } - } - - private void displayMediaDetails() { - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.refresh(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onMediaRefreshed, Timber::e), - mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateCategoryList, Timber::e), - mediaDataExtractor.checkDeletionRequestExists(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDeletionPageExists, Timber::e), - mediaDataExtractor.fetchDiscussion(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDiscussionLoaded, Timber::e) - ); - } - - private void onMediaRefreshed(Media media) { - media.setCategories(this.media.getCategories()); - this.media = media; - setTextFields(media); - compositeDisposable.addAll( - mediaDataExtractor.fetchDepictionIdsAndLabels(media) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDepictionsLoaded, Timber::e) - ); - // compositeDisposable.add(disposable); - } - - private void onDiscussionLoaded(String discussion) { - binding.mediaDetailDisc.setText(prettyDiscussion(discussion.trim())); - } - - private void onDeletionPageExists(Boolean deletionPageExists) { - if (AccountUtilKt.getUserName(getContext()) == null && !AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - binding.nominateDeletion.setVisibility(GONE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } else if (deletionPageExists) { - if (applicationKvStore.getBoolean( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove( - String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - binding.progressBarDeletion.setVisibility(GONE); - } - binding.nominateDeletion.setVisibility(GONE); - - binding.nominatedDeletionBanner.setVisibility(VISIBLE); - } else if (!isCategoryImage) { - binding.nominateDeletion.setVisibility(VISIBLE); - binding.nominatedDeletionBanner.setVisibility(GONE); - } - } - - private void onDepictionsLoaded(List idAndCaptions){ - binding.depictsLayout.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - binding.depictionsEditButton.setVisibility(idAndCaptions.isEmpty() ? GONE : VISIBLE); - buildDepictionList(idAndCaptions); - } - - /** - * By clicking on the edit depictions button, it will send user to depict fragment - */ - - public void onDepictionsEditButtonClicked() { - binding.mediaDetailDepictionContainer.removeAllViews(); - binding.depictionsEditButton.setVisibility(GONE); - final Fragment depictsFragment = new DepictsFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Depicts", media); - depictsFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - /** - * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView - * which holds the image to be displayed( moreover this image is out of - * the scroll view ) - * - * - * If the image is sufficiently large i.e. the image height extends the view height, we reduce - * the height and change the width to maintain the aspect ratio, otherwise image takes up the - * total possible width and height is adjusted accordingly. - * - * @param scrollWidth the current width of the scrollView - */ - private void updateAspectRatio(int scrollWidth) { - if (imageInfoCache != null) { - int finalHeight = (scrollWidth*imageInfoCache.getHeight()) / imageInfoCache.getWidth(); - ViewGroup.LayoutParams params = binding.mediaDetailImageView.getLayoutParams(); - ViewGroup.LayoutParams spacerParams = binding.mediaDetailImageViewSpacer.getLayoutParams(); - params.width = scrollWidth; - if(finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { - - // Adjust the height and width of image. - int temp = frameLayoutHeight - minimumHeightOfMetadata; - params.width = (scrollWidth*temp) / finalHeight; - finalHeight = temp; - - } - params.height = finalHeight; - spacerParams.height = finalHeight; - binding.mediaDetailImageView.setLayoutParams(params); - binding.mediaDetailImageViewSpacer.setLayoutParams(spacerParams); - } - } - - private final ControllerListener aspectRatioListener = new BaseControllerListener() { - @Override - public void onIntermediateImageSet(String id, @Nullable ImageInfo imageInfo) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - @Override - public void onFinalImageSet(String id, @Nullable ImageInfo imageInfo, @Nullable Animatable animatable) { - imageInfoCache = imageInfo; - updateAspectRatio(binding.mediaDetailScrollView.getWidth()); - } - }; - - /** - * Uses two image sources. - * - low resolution thumbnail is shown initially - * - when the high resolution image is available, it replaces the low resolution image - */ - private void setupImageView() { - int imageBackgroundColor = getImageBackgroundColor(); - if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { - binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor); - } - - binding.mediaDetailImageView.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder); - binding.mediaDetailImageView.getHierarchy().setFailureImage(R.drawable.image_placeholder); - - DraweeController controller = Fresco.newDraweeControllerBuilder() - .setLowResImageRequest(ImageRequest.fromUri(media != null ? media.getThumbUrl() : null)) - .setRetainImageOnFailure(true) - .setImageRequest(ImageRequest.fromUri(media != null ? media.getImageUrl() : null)) - .setControllerListener(aspectRatioListener) - .setOldController(binding.mediaDetailImageView.getController()) - .build(); - binding.mediaDetailImageView.setController(controller); - } - - private void updateToDoWarning() { - String toDoMessage = ""; - boolean toDoNeeded = false; - boolean categoriesPresent = media.getCategories() == null ? false : (media.getCategories().size() == 0 ? false : true); - - // Check if the presented category is about need of category - if (categoriesPresent) { - for (String category : media.getCategories()) { - if (category.toLowerCase(Locale.ROOT).contains(CATEGORY_NEEDING_CATEGORIES) || - category.toLowerCase(Locale.ROOT).contains(CATEGORY_UNCATEGORISED)) { - categoriesPresent = false; - } - break; - } - } - if (!categoriesPresent) { - toDoNeeded = true; - toDoMessage += getString(R.string.missing_category); - } - if (isWikipediaButtonDisplayed) { - toDoNeeded = true; - toDoMessage += (toDoMessage.isEmpty()) ? "" : "\n" + getString(R.string.missing_article); - } - - if (toDoNeeded) { - toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage; - binding.toDoLayout.setVisibility(VISIBLE); - binding.toDoReason.setText(toDoMessage); - } else { - binding.toDoLayout.setVisibility(GONE); - } - } - - @Override - public void onDestroyView() { - if (layoutListener != null && getView() != null) { - getView().getViewTreeObserver().removeGlobalOnLayoutListener(layoutListener); // old Android was on crack. CRACK IS WHACK - layoutListener = null; - } - - compositeDisposable.clear(); - super.onDestroyView(); - } - - private void setTextFields(Media media) { - setupImageView(); - binding.mediaDetailTitle.setText(media.getDisplayTitle()); - binding.mediaDetailDesc.setHtmlText(prettyDescription(media)); - binding.mediaDetailLicense.setText(prettyLicense(media)); - binding.mediaDetailCoordinates.setText(prettyCoordinates(media)); - binding.mediaDetailuploadeddate.setText(prettyUploadedDate(media)); - if (prettyCaption(media).equals(getContext().getString(R.string.detail_caption_empty))) { - binding.captionLayout.setVisibility(GONE); - } else { - binding.mediaDetailCaption.setText(prettyCaption(media)); - } - - categoryNames.clear(); - categoryNames.addAll(media.getCategories()); - - if (media.getAuthor() == null || media.getAuthor().equals("")) { - binding.authorLinearLayout.setVisibility(GONE); - } else { - binding.mediaDetailAuthor.setText(media.getAuthor()); - } - } - - /** - * Gets new categories from the WikiText and updates it on the UI - * - * @param s WikiText - */ - private void updateCategoryList(final String s) { - final List allCategories = new ArrayList(); - int i = s.indexOf("[[Category:"); - while(i != -1){ - final String category = s.substring(i+11, s.indexOf("]]", i)); - allCategories.add(category); - i = s.indexOf("]]", i); - i = s.indexOf("[[Category:", i); - } - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - if(sessionManager.isUserLoggedIn()) { - binding.categoryEditButton.setVisibility(VISIBLE); - } - rebuildCatList(allCategories); - } - - /** - * Updates the categories - */ - public void updateCategories() { - List allCategories = new ArrayList(media.getAddedCategories()); - media.setCategories(allCategories); - if (allCategories.isEmpty()) { - // Stick in a filler element. - allCategories.add(getString(R.string.detail_panel_cats_none)); - } - - rebuildCatList(allCategories); - } - - /** - * Populates media details fragment with depiction list - * @param idAndCaptions - */ - private void buildDepictionList(List idAndCaptions) { - binding.mediaDetailDepictionContainer.removeAllViews(); - String locale = Locale.getDefault().getLanguage(); - for (IdAndCaptions idAndCaption : idAndCaptions) { - binding.mediaDetailDepictionContainer.addView(buildDepictLabel( - getDepictionCaption(idAndCaption, locale), - idAndCaption.getId(), - binding.mediaDetailDepictionContainer - )); - } - } - - private String getDepictionCaption(IdAndCaptions idAndCaption, String locale) { - //Check if the Depiction Caption is available in user's locale if not then check for english, else show any available. - if(idAndCaption.getCaptions().get(locale) != null) { - return idAndCaption.getCaptions().get(locale); - } - if(idAndCaption.getCaptions().get("en") != null) { - return idAndCaption.getCaptions().get("en"); - } - return idAndCaption.getCaptions().values().iterator().next(); - } - - public void onMediaDetailLicenceClicked(){ - String url = media.getLicenseUrl(); - if (!StringUtils.isBlank(url) && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(url)); - } else { - viewUtil.showShortToast(getActivity(), getString(R.string.null_url)); - } - } - - public void onMediaDetailCoordinatesClicked(){ - if (media.getCoordinates() != null && getActivity() != null) { - Utils.handleGeoCoordinates(getActivity(), media.getCoordinates()); - } - } - - public void onCopyWikicodeClicked() { - String data = - "[[" + media.getFilename() + "|thumb|" + media.getFallbackDescription() + "]]"; - Utils.copy("wikiCode", data, getContext()); - Timber.d("Generated wikidata copy code: %s", data); - - Toast.makeText(getContext(), getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) - .show(); - } - - /** - * Sends thanks to author if the author is not the user - */ - public void sendThanksToAuthor() { - String fileName = media.getFilename(); - if (TextUtils.isEmpty(fileName)) { - Toast.makeText(getContext(), getString(R.string.error_sending_thanks), - Toast.LENGTH_SHORT).show(); - return; - } - compositeDisposable.add(reviewHelper.getFirstRevisionOfFile(fileName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(revision -> sendThanks(getContext(), revision))); - } - - /** - * Api call for sending thanks to the author when the author is not the user - * and display toast depending on the result - * @param context context - * @param firstRevision the revision id of the image - */ - @SuppressLint({"CheckResult", "StringFormatInvalid"}) - void sendThanks(Context context, MwQueryPage.Revision firstRevision) { - ViewUtil.showShortToast(context, - context.getString(R.string.send_thank_toast, media.getDisplayTitle())); - - if (firstRevision == null) { - return; - } - - Observable.defer((Callable>) () -> thanksClient.thank( - firstRevision.getRevisionId())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - displayThanksToast(getContext(), result); - }, throwable -> { - if (throwable instanceof InvalidLoginTokenException) { - final String username = sessionManager.getUserName(); - final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener( - getActivity(), - requireActivity().getString(R.string.invalid_login_message), - username - ); - - CommonsApplication.getInstance().clearApplicationData( - requireActivity(), logoutListener); - } else { - Timber.e(throwable); - } - }); - } - - /** - * Method to display toast when api call to thank the author is completed - * @param context context - * @param result true if success, false otherwise - */ - @SuppressLint("StringFormatInvalid") - private void displayThanksToast(final Context context, final boolean result) { - final String message; - final String title; - if (result) { - title = context.getString(R.string.send_thank_success_title); - message = context.getString(R.string.send_thank_success_message, - media.getDisplayTitle()); - } else { - title = context.getString(R.string.send_thank_failure_title); - message = context.getString(R.string.send_thank_failure_message, - media.getDisplayTitle()); - } - - ViewUtil.showShortToast(context, message); - } - - public void onCategoryEditButtonClicked(){ - binding.progressBarEditCategory.setVisibility(VISIBLE); - binding.categoryEditButton.setVisibility(GONE); - getWikiText(); - } - - /** - * Gets WikiText from the server and send it to catgory editor - */ - private void getWikiText() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::gotoCategoryEditor, Timber::e)); - } - - /** - * Opens the category editor - * - * @param s WikiText - */ - private void gotoCategoryEditor(final String s) { - binding.categoryEditButton.setVisibility(VISIBLE); - binding.progressBarEditCategory.setVisibility(GONE); - final Fragment categoriesFragment = new UploadCategoriesFragment(); - final Bundle bundle = new Bundle(); - bundle.putParcelable("Existing_Categories", media); - bundle.putString("WikiText", s); - categoriesFragment.setArguments(bundle); - final FragmentTransaction transaction = getChildFragmentManager().beginTransaction(); - transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment); - transaction.addToBackStack(null); - transaction.commit(); - } - - public void onUpdateCoordinatesClicked(){ - goToLocationPickerActivity(); - } - - /** - * Start location picker activity with a request code and get the coordinates from the activity. - */ - private void goToLocationPickerActivity() { - /* - If location is not provided in media this coordinates will act as a placeholder in - location picker activity - */ - double defaultLatitude = 37.773972; - double defaultLongitude = -122.431297; - if (media.getCoordinates() != null) { - defaultLatitude = media.getCoordinates().getLatitude(); - defaultLongitude = media.getCoordinates().getLongitude(); - } else { - if(locationManager.getLastLocation()!=null) { - defaultLatitude = locationManager.getLastLocation().getLatitude(); - defaultLongitude = locationManager.getLastLocation().getLongitude(); - } else { - String[] lastLocation = applicationKvStore.getString(LAST_LOCATION,(defaultLatitude + "," + defaultLongitude)).split(","); - defaultLatitude = Double.parseDouble(lastLocation[0]); - defaultLongitude = Double.parseDouble(lastLocation[1]); - } - } - - - startActivity(new LocationPicker.IntentBuilder() - .defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,16.0)) - .activityKey("MediaActivity") - .media(media) - .build(getActivity())); - } - - public void onDescriptionEditClicked() { - binding.progressBarEdit.setVisibility(VISIBLE); - binding.descriptionEdit.setVisibility(GONE); - getDescriptionList(); - } - - /** - * Gets descriptions from wikitext - */ - private void getDescriptionList() { - compositeDisposable.add(mediaDataExtractor.getCurrentWikiText( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractCaptionDescription, Timber::e)); - } - - /** - * Gets captions and descriptions and merge them according to language code and arranges it in a - * single list. - * Send the list to DescriptionEditActivity - * @param s wikitext - */ - private void extractCaptionDescription(final String s) { - final LinkedHashMap descriptions = getDescriptions(s); - final LinkedHashMap captions = getCaptionsList(); - - final ArrayList descriptionAndCaptions = new ArrayList<>(); - - if(captions.size() >= descriptions.size()) { - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - (String) mapElement.getValue()) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, "", - (String) mapElement.getValue()) - ); - } - } - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - Objects.requireNonNull(descriptions.get(language)), - "") - ); - } - } - } else { - for (final Map.Entry mapElement : descriptions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (captions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - Objects.requireNonNull(captions.get(language))) - ); - } else { - descriptionAndCaptions.add( - new UploadMediaDetail(language, (String) mapElement.getValue(), - "") - ); - } - } - for (final Map.Entry mapElement : captions.entrySet()) { - - final String language = (String) mapElement.getKey(); - if (!descriptions.containsKey(language)) { - descriptionAndCaptions.add( - new UploadMediaDetail(language, - "", - Objects.requireNonNull(descriptions.get(language))) - ); - } - } - } - final Intent intent = new Intent(requireContext(), DescriptionEditActivity.class); - final Bundle bundle = new Bundle(); - bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions); - bundle.putString(WIKITEXT, s); - bundle.putString(Prefs.DESCRIPTION_LANGUAGE, applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "")); - bundle.putParcelable("media", media); - intent.putExtras(bundle); - startActivity(intent); - } - - /** - * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the - * language code - * @param s wikitext - * @return LinkedHashMap - */ - private LinkedHashMap getDescriptions(String s) { - final Pattern pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL); - final Matcher matcher = pattern.matcher(s); - String description = null; - if (matcher.find()) { - description = matcher.group(); - } - if(description == null){ - return new LinkedHashMap<>(); - } - - final LinkedHashMap descriptionList = new LinkedHashMap<>(); - - int count = 0; // number of "{{" - int startCode = 0; - int endCode = 0; - int startDescription = 0; - int endDescription = 0; - final HashSet allLanguageCodes = new HashSet<>(Arrays.asList("en","es","de","ja","fr","ru","pt","it","zh-hans","zh-hant","ar","ko","id","pl","nl","fa","hi","th","vi","sv","uk","cs","simple","hu","ro","fi","el","he","nb","da","sr","hr","ms","bg","ca","tr","sk","sh","bn","tl","mr","ta","kk","lt","az","bs","sl","sq","arz","zh-yue","ka","te","et","lv","ml","hy","uz","kn","af","nn","mk","gl","sw","eu","ur","ky","gu","bh","sco","ast","is","mn","be","an","km","si","ceb","jv","eo","als","ig","su","be-x-old","la","my","cy","ne","bar","azb","mzn","as","am","so","pa","map-bms","scn","tg","ckb","ga","lb","war","zh-min-nan","nds","fy","vec","pnb","zh-classical","lmo","tt","io","ia","br","hif","mg","wuu","gan","ang","or","oc","yi","ps","tk","ba","sah","fo","nap","vls","sa","ce","qu","ku","min","bcl","ilo","ht","li","wa","vo","nds-nl","pam","new","mai","sn","pms","eml","yo","ha","gn","frr","gd","hsb","cv","lo","os","se","cdo","sd","ksh","bat-smg","bo","nah","xmf","ace","roa-tara","hak","bjn","gv","mt","pfl","szl","bpy","rue","co","diq","sc","rw","vep","lij","kw","fur","pcd","lad","tpi","ext","csb","rm","kab","gom","udm","mhr","glk","za","pdc","om","iu","nv","mi","nrm","tcy","frp","myv","kbp","dsb","zu","ln","mwl","fiu-vro","tum","tet","tn","pnt","stq","nov","ny","xh","crh","lfn","st","pap","ay","zea","bxr","kl","sm","ak","ve","pag","nso","kaa","lez","gag","kv","bm","to","lbe","krc","jam","ss","roa-rup","dv","ie","av","cbk-zam","chy","inh","ug","ch","arc","pih","mrj","kg","rmy","dty","na","ts","xal","wo","fj","tyv","olo","ltg","ff","jbo","haw","ki","chr","sg","atj","sat","ady","ty","lrc","ti","din","gor","lg","rn","bi","cu","kbd","pi","cr","koi","ik","mdf","bug","ee","shn","tw","dz","srn","ks","test","en-x-piglatin","ab")); - for (int i = 0; i < description.length() - 1; i++) { - if (description.startsWith("{{", i)) { - if (count == 0) { - startCode = i; - endCode = description.indexOf("|", i); - startDescription = endCode + 1; - if (description.startsWith("1=", endCode + 1)) { - startDescription += 2; - i += 2; - } - } - i++; - count++; - } else if (description.startsWith("}}", i)) { - count--; - if (count == 0) { - endDescription = i; - final String languageCode = description.substring(startCode + 2, endCode); - final String languageDescription = description.substring(startDescription, endDescription); - if (allLanguageCodes.contains(languageCode)) { - descriptionList.put(languageCode, languageDescription); - } - } - i++; - } - } - return descriptionList; - } - - /** - * Gets list of caption and arranges it in a LinkedHashmap according to the language code - * @return LinkedHashMap - */ - private LinkedHashMap getCaptionsList() { - final LinkedHashMap captionList = new LinkedHashMap<>(); - final Map captions = media.getCaptions(); - for (final Map.Entry map : captions.entrySet()) { - final String language = map.getKey(); - final String languageCaption = map.getValue(); - captionList.put(language, languageCaption); - } - return captionList; - } - - /** - * Adds caption to the map and updates captions - * @param mediaDetail UploadMediaDetail - * @param updatedCaptions updated captionds - */ - private void updateCaptions(UploadMediaDetail mediaDetail, - LinkedHashMap updatedCaptions) { - updatedCaptions.put(mediaDetail.getLanguageCode(), mediaDetail.getCaptionText()); - media.setCaptions(updatedCaptions); - } - - @SuppressLint("StringFormatInvalid") - public void onDeleteButtonClicked(){ - if (AccountUtilKt.getUserName(getContext()) != null && AccountUtilKt.getUserName(getContext()).equals(media.getAuthor())) { - final ArrayAdapter languageAdapter = new ArrayAdapter<>(getActivity(), - R.layout.simple_spinner_dropdown_list, reasonList); - final Spinner spinner = new Spinner(getActivity()); - spinner.setLayoutParams( - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT)); - spinner.setAdapter(languageAdapter); - spinner.setGravity(17); - - AlertDialog dialog = DialogUtil.showAlertDialog(getActivity(), - getString(R.string.nominate_delete), - null, - getString(R.string.about_translate_proceed), - getString(R.string.about_translate_cancel), - () -> onDeleteClicked(spinner), - () -> {}, - spinner, - true); - if (isDeleted) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - //Reviewer correct me if i have misunderstood something over here - //But how does this if (delete.getVisibility() == View.VISIBLE) { - // enableDeleteButton(true); makes sense ? - else if (AccountUtilKt.getUserName(getContext()) != null) { - final EditText input = new EditText(getActivity()); - input.requestFocus(); - AlertDialog d = DialogUtil.showAlertDialog(getActivity(), - null, - getString(R.string.dialog_box_text_nomination, media.getDisplayTitle()), - getString(R.string.ok), - getString(R.string.cancel), - () -> { - String reason = input.getText().toString(); - onDeleteClickeddialogtext(reason); - }, - () -> {}, - input, - true); - input.addTextChangedListener(new TextWatcher() { - private void handleText() { - final Button okButton = d.getButton(AlertDialog.BUTTON_POSITIVE); - if (input.getText().length() == 0 || isDeleted) { - okButton.setEnabled(false); - } else { - okButton.setEnabled(true); - } - } - - @Override - public void afterTextChanged(Editable arg0) { - handleText(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - }); - d.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); - } - } - - @SuppressLint("CheckResult") - private void onDeleteClicked(Spinner spinner) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - String reason = reasonListEnglishMappings.get(spinner.getSelectedItemPosition()); - String finalReason = reason; - Single resultSingle = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, finalReason)); - resultSingle - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - @SuppressLint("CheckResult") - private void onDeleteClickeddialogtext(String reason) { - applicationKvStore.putBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), true); - enableProgressBar(); - Single resultSingletext = reasonBuilder.getReason(media, reason) - .flatMap(reasonString -> deleteHelper.makeDeletion(getContext(), media, reason)); - resultSingletext - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(s -> { - if(applicationKvStore.getBoolean(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl()), false)) { - applicationKvStore.remove(String.format(NOMINATING_FOR_DELETION_MEDIA, media.getImageUrl())); - callback.nominatingForDeletion(index); - } - }); - } - - public void onSeeMoreClicked(){ - if (binding.nominatedDeletionBanner.getVisibility() == VISIBLE && getActivity() != null) { - Utils.handleWebUrl(getActivity(), Uri.parse(media.getPageTitle().getMobileUri())); - } - } - - public void onAuthorViewClicked() { - if (media == null || media.getUser() == null) { - return; - } - if (sessionManager.getUserName() == null) { - String userProfileLink = BuildConfig.COMMONS_URL + "/wiki/User:" + media.getUser(); - Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)); - startActivity(browserIntent); - return; - } - ProfileActivity.startYourself(getActivity(), media.getUser(), !Objects - .equals(sessionManager.getUserName(), media.getUser())); - } - - /** - * Enable Progress Bar and Update delete button text. - */ - private void enableProgressBar() { - binding.progressBarDeletion.setVisibility(VISIBLE); - binding.nominateDeletion.setText("Nominating for Deletion"); - isDeleted = true; - } - - private void rebuildCatList(List categories) { - binding.mediaDetailCategoryContainer.removeAllViews(); - for (String category : categories) { - binding.mediaDetailCategoryContainer.addView(buildCatLabel(sanitise(category), binding.mediaDetailCategoryContainer)); - } - } - - //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), some categories come suffixed with strings prefixed with |. As per the discussion - //that was meant for alphabetical sorting of the categories and can be safely removed. - private String sanitise(String category) { - int indexOfPipe = category.indexOf('|'); - if (indexOfPipe != -1) { - //Removed everything after '|' - return category.substring(0, indexOfPipe); - } - return category; - } - - /** - * Add view to depictions obtained also tapping on depictions should open the url - */ - private View buildDepictLabel(String depictionName, String entityId, LinearLayout depictionContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, depictionContainer,false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - textView.setText(depictionName); - item.setOnClickListener(view -> { - Intent intent = new Intent(getContext(), WikidataItemDetailsActivity.class); - intent.putExtra("wikidataItemName", depictionName); - intent.putExtra("entityId", entityId); - intent.putExtra("fragment", "MediaDetailFragment"); - getContext().startActivity(intent); - }); - return item; - } - - private View buildCatLabel(final String catName, ViewGroup categoryContainer) { - final View item = LayoutInflater.from(getContext()).inflate(R.layout.detail_category_item, categoryContainer, false); - final TextView textView = item.findViewById(R.id.mediaDetailCategoryItemText); - - textView.setText(catName); - if(!getString(R.string.detail_panel_cats_none).equals(catName)) { - textView.setOnClickListener(view -> { - // Open Category Details page - Intent intent = new Intent(getContext(), CategoryDetailsActivity.class); - intent.putExtra("categoryName", catName); - getContext().startActivity(intent); - }); - } - return item; - } - - /** - * Returns captions for media details - * - * @param media object of class media - * @return caption as string - */ - private String prettyCaption(Media media) { - for (String caption : media.getCaptions().values()) { - if (caption.equals("")) { - return getString(R.string.detail_caption_empty); - } else { - return caption; - } - } - return getString(R.string.detail_caption_empty); - } - - private String prettyDescription(Media media) { - String description = chooseDescription(media); - if (!description.isEmpty()) { - // Remove img tag that sometimes appears as a blue square in the app, - // see https://github.com/commons-app/apps-android-commons/issues/4345 - description = description.replaceAll("[<](/)?img[^>]*[>]", ""); - } - return description.isEmpty() ? getString(R.string.detail_description_empty) - : description; - } - - private String chooseDescription(Media media) { - final Map descriptions = media.getDescriptions(); - final String multilingualDesc = descriptions.get(Locale.getDefault().getLanguage()); - if (multilingualDesc != null) { - return multilingualDesc; - } - for (String description : descriptions.values()) { - return description; - } - return media.getFallbackDescription(); - } - - private String prettyDiscussion(String discussion) { - return discussion.isEmpty() ? getString(R.string.detail_discussion_empty) : discussion; - } - - private String prettyLicense(Media media) { - String licenseKey = media.getLicense(); - Timber.d("Media license is: %s", licenseKey); - if (licenseKey == null || licenseKey.equals("")) { - return getString(R.string.detail_license_empty); - } - return licenseKey; - } - - private String prettyUploadedDate(Media media) { - Date date = media.getDateUploaded(); - if (date == null || date.toString() == null || date.toString().isEmpty()) { - return "Uploaded date not available"; - } - return DateUtil.getDateStringWithSkeletonPattern(date, "dd MMM yyyy"); - } - - /** - * Returns the coordinates nicely formatted. - * - * @return Coordinates as text. - */ - private String prettyCoordinates(Media media) { - if (media.getCoordinates() == null) { - return getString(R.string.media_detail_coordinates_empty); - } - return media.getCoordinates().getPrettyCoordinateString(); - } - - @Override - public boolean updateCategoryDisplay(List categories) { - if (categories == null) { - return false; - } else { - rebuildCatList(categories); - return true; - } - } - - void showCaptionAndDescription() { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == GONE) { - binding.dummyCaptionDescriptionContainer.setVisibility(VISIBLE); - setUpCaptionAndDescriptionLayout(); - } else { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - } - } - - /** - * setUp Caption And Description Layout - */ - private void setUpCaptionAndDescriptionLayout() { - List captions = getCaptions(); - - if (descriptionHtmlCode == null) { - binding.showCaptionsBinding.pbCircular.setVisibility(VISIBLE); - } - - getDescription(); - CaptionListViewAdapter adapter = new CaptionListViewAdapter(captions); - binding.showCaptionsBinding.captionListview.setAdapter(adapter); - } - - /** - * Generate the caption with language - */ - private List getCaptions() { - List captionList = new ArrayList<>(); - Map captions = media.getCaptions(); - AppLanguageLookUpTable appLanguageLookUpTable = new AppLanguageLookUpTable(getContext()); - for (Map.Entry map : captions.entrySet()) { - String language = appLanguageLookUpTable.getLocalizedName(map.getKey()); - String languageCaption = map.getValue(); - captionList.add(new Caption(language, languageCaption)); - } - - if (captionList.size() == 0) { - captionList.add(new Caption("", "No Caption")); - } - return captionList; - } - - private void getDescription() { - compositeDisposable.add(mediaDataExtractor.getHtmlOfPage( - Objects.requireNonNull(media.getFilename())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::extractDescription, Timber::e)); - } - - /** - * extract the description from html of imagepage - */ - private void extractDescription(String s) { - String descriptionClassName = ""; - int start = s.indexOf(descriptionClassName) + descriptionClassName.length(); - int end = s.indexOf("", start); - descriptionHtmlCode = ""; - for (int i = start; i < end; i++) { - descriptionHtmlCode = descriptionHtmlCode + s.toCharArray()[i]; - } - - binding.showCaptionsBinding.descriptionWebview - .loadDataWithBaseURL(null, descriptionHtmlCode, "text/html", "utf-8", null); - binding.showCaptionsBinding.pbCircular.setVisibility(GONE); - } - - /** - * Handle back event when fragment when showCaptionAndDescriptionContainer is visible - */ - private void handleBackEvent(View view) { - view.setFocusableInTouchMode(true); - view.requestFocus(); - view.setOnKeyListener(new OnKeyListener() { - @Override - public boolean onKey(View view, int keycode, KeyEvent keyEvent) { - if (keycode == KeyEvent.KEYCODE_BACK) { - if (binding.dummyCaptionDescriptionContainer.getVisibility() == VISIBLE) { - binding.dummyCaptionDescriptionContainer.setVisibility(GONE); - return true; - } - } - return false; - } - }); - - } - - - public interface Callback { - void nominatingForDeletion(int index); - } - - /** - * Called when the image background color is changed. - * You should pass a useable color, not a resource id. - * @param color - */ - public void onImageBackgroundChanged(int color) { - int currentColor = getImageBackgroundColor(); - if (currentColor == color) { - return; - } - - binding.mediaDetailImageView.setBackgroundColor(color); - getImageBackgroundColorPref().edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply(); - } - - private SharedPreferences getImageBackgroundColorPref() { - return getContext().getSharedPreferences(IMAGE_BACKGROUND_COLOR + media.getPageId(), Context.MODE_PRIVATE); - } - - private int getImageBackgroundColor() { - SharedPreferences imageBackgroundColorPref = this.getImageBackgroundColorPref(); - return imageBackgroundColorPref.getInt(IMAGE_BACKGROUND_COLOR, DEFAULT_IMAGE_BACKGROUND_COLOR); - } -} 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 new file mode 100644 index 0000000000..03eeac61e8 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailFragment.kt @@ -0,0 +1,2233 @@ +package fr.free.nrw.commons.media + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.drawable.Animatable +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.viewModels +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.drawee.controller.BaseControllerListener +import com.facebook.drawee.controller.ControllerListener +import com.facebook.drawee.interfaces.DraweeController +import com.facebook.imagepipeline.image.ImageInfo +import com.facebook.imagepipeline.request.ImageRequest +import fr.free.nrw.commons.BuildConfig +import fr.free.nrw.commons.CameraPosition +import fr.free.nrw.commons.CommonsApplication +import fr.free.nrw.commons.CommonsApplication.Companion.instance +import fr.free.nrw.commons.LocationPicker.LocationPicker +import fr.free.nrw.commons.Media +import fr.free.nrw.commons.MediaDataExtractor +import fr.free.nrw.commons.R +import fr.free.nrw.commons.Utils +import fr.free.nrw.commons.actions.ThanksClient +import fr.free.nrw.commons.auth.SessionManager +import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException +import fr.free.nrw.commons.auth.getUserName +import fr.free.nrw.commons.category.CATEGORY_NEEDING_CATEGORIES +import fr.free.nrw.commons.category.CATEGORY_UNCATEGORISED +import fr.free.nrw.commons.category.CategoryClient +import fr.free.nrw.commons.category.CategoryDetailsActivity +import fr.free.nrw.commons.category.CategoryEditHelper +import fr.free.nrw.commons.contributions.ContributionsFragment +import fr.free.nrw.commons.coordinates.CoordinateEditHelper +import fr.free.nrw.commons.databinding.FragmentMediaDetailBinding +import fr.free.nrw.commons.delete.DeleteHelper +import fr.free.nrw.commons.delete.ReasonBuilder +import fr.free.nrw.commons.description.DescriptionEditActivity +import fr.free.nrw.commons.description.DescriptionEditHelper +import fr.free.nrw.commons.description.EditDescriptionConstants.LIST_OF_DESCRIPTION_AND_CAPTION +import fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT +import fr.free.nrw.commons.di.CommonsDaggerSupportFragment +import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity +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.media.MediaDetailPagerFragment.MediaDetailProvider +import fr.free.nrw.commons.profile.ProfileActivity +import fr.free.nrw.commons.review.ReviewHelper +import fr.free.nrw.commons.settings.Prefs +import fr.free.nrw.commons.upload.UploadMediaDetail +import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment +import fr.free.nrw.commons.upload.depicts.DepictsFragment +import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment +import fr.free.nrw.commons.utils.DateUtil.getDateStringWithSkeletonPattern +import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog +import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources +import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE +import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction +import fr.free.nrw.commons.utils.PermissionUtils.hasPermission +import fr.free.nrw.commons.utils.ViewUtil.showShortToast +import fr.free.nrw.commons.utils.ViewUtilWrapper +import fr.free.nrw.commons.wikidata.mwapi.MwQueryPage.Revision +import io.reactivex.Observable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.apache.commons.lang3.StringUtils +import timber.log.Timber +import java.util.Date +import java.util.Locale +import java.util.Objects +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.inject.Inject +import javax.inject.Named + +class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.Callback { + private var editable: Boolean = false + private var isCategoryImage: Boolean = false + private var detailProvider: MediaDetailProvider? = null + private var index: Int = 0 + private var isDeleted: Boolean = false + private var isWikipediaButtonDisplayed: Boolean = false + private val callback: Callback? = null + + @Inject + lateinit var mediaDetailViewModelFactory: MediaDetailViewModel.MediaDetailViewModelProviderFactory + + @Inject + lateinit var locationManager: LocationServiceManager + + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var mediaDataExtractor: MediaDataExtractor + + @Inject + lateinit var reasonBuilder: ReasonBuilder + + @Inject + lateinit var deleteHelper: DeleteHelper + + @Inject + lateinit var reviewHelper: ReviewHelper + + @Inject + lateinit var categoryEditHelper: CategoryEditHelper + + @Inject + lateinit var coordinateEditHelper: CoordinateEditHelper + + @Inject + lateinit var descriptionEditHelper: DescriptionEditHelper + + @Inject + lateinit var viewUtil: ViewUtilWrapper + + @Inject + lateinit var categoryClient: CategoryClient + + @Inject + lateinit var thanksClient: ThanksClient + + @Inject + @field:Named("default_preferences") + lateinit var applicationKvStore: JsonKvStore + + private val viewModel: MediaDetailViewModel by viewModels { mediaDetailViewModelFactory } + + private var initialListTop: Int = 0 + + private var _binding: FragmentMediaDetailBinding? = null + private val binding get() = _binding!! + + private var descriptionHtmlCode: String? = null + + + private val categoryNames: ArrayList = ArrayList() + + /** + * Depicts is a feature part of Structured data. + * Multiple Depictions can be added for an image just like categories. + * However unlike categories depictions is multi-lingual + * Ex: key: en value: monument + */ + private var imageInfoCache: ImageInfo? = null + private var oldWidthOfImageView: Int = 0 + private var newWidthOfImageView: Int = 0 + private var heightVerifyingBoolean: Boolean = true // helps in maintaining aspect ratio + private var layoutListener: OnGlobalLayoutListener? = null // for layout stuff, only used once! + + //Had to make this class variable, to implement various onClicks, which access the media, + // also I fell why make separate variables when one can serve the purpose + private var media: Media? = null + private lateinit var reasonList: ArrayList + private lateinit var reasonListEnglishMappings: ArrayList + + /** + * Height stores the height of the frame layout as soon as it is initialised + * and updates itself on configuration changes. + * Used to adjust aspect ratio of image when length of the image is too large. + */ + private var frameLayoutHeight: Int = 0 + + /** + * Minimum height of the metadata, in pixels. + * Images with a very narrow aspect ratio will be reduced so that the metadata information + * panel always has at least this height. + */ + private val minimumHeightOfMetadata: Int = 200 + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt("index", index) + outState.putBoolean("editable", editable) + outState.putBoolean("isCategoryImage", isCategoryImage) + outState.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + + scrollPosition + outState.putInt("listTop", initialListTop) + } + + private val scrollPosition: Unit + get() { + initialListTop = binding.mediaDetailScrollView.scrollY + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + if (parentFragment != null + && parentFragment is MediaDetailPagerFragment + ) { + detailProvider = + (parentFragment as MediaDetailPagerFragment).mediaDetailProvider + } + if (savedInstanceState != null) { + editable = savedInstanceState.getBoolean("editable") + isCategoryImage = savedInstanceState.getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = savedInstanceState.getBoolean("isWikipediaButtonDisplayed") + index = savedInstanceState.getInt("index") + initialListTop = savedInstanceState.getInt("listTop") + } else { + editable = requireArguments().getBoolean("editable") + isCategoryImage = requireArguments().getBoolean("isCategoryImage") + isWikipediaButtonDisplayed = requireArguments().getBoolean("isWikipediaButtonDisplayed") + index = requireArguments().getInt("index") + initialListTop = 0 + } + + reasonList = ArrayList() + reasonList.add(getString(R.string.deletion_reason_uploaded_by_mistake)) + reasonList.add(getString(R.string.deletion_reason_publicly_visible)) + reasonList.add(getString(R.string.deletion_reason_not_interesting)) + reasonList.add(getString(R.string.deletion_reason_no_longer_want_public)) + reasonList.add(getString(R.string.deletion_reason_bad_for_my_privacy)) + + // Add corresponding mappings in english locale so that we can upload it in deletion request + reasonListEnglishMappings = ArrayList() + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_uploaded_by_mistake) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_publicly_visible) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_not_interesting) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_no_longer_want_public) + ) + reasonListEnglishMappings.add( + getLocalizedResources( + requireContext(), + Locale.ENGLISH + ).getString(R.string.deletion_reason_bad_for_my_privacy) + ) + + _binding = FragmentMediaDetailBinding.inflate(inflater, container, false) + val view: View = binding.root + + + Utils.setUnderlinedText(binding.seeMore, R.string.nominated_see_more, requireContext()) + + if (isCategoryImage) { + binding.authorLinearLayout.visibility = View.VISIBLE + } else { + binding.authorLinearLayout.visibility = View.GONE + } + + if (!sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.GONE + binding.descriptionEdit.visibility = View.GONE + binding.depictionsEditButton.visibility = View.GONE + } else { + binding.categoryEditButton.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.VISIBLE + binding.depictionsEditButton.visibility = View.VISIBLE + } + + if (applicationKvStore.getBoolean("login_skipped")) { + binding.nominateDeletion.visibility = View.GONE + binding.coordinateEdit.visibility = View.GONE + } + + handleBackEvent(view) + + //set onCLick listeners + binding.mediaDetailLicense.setOnClickListener { onMediaDetailLicenceClicked() } + binding.mediaDetailCoordinates.setOnClickListener { onMediaDetailCoordinatesClicked() } + binding.sendThanks.setOnClickListener { sendThanksToAuthor() } + binding.dummyCaptionDescriptionContainer.setOnClickListener { showCaptionAndDescription() } + binding.mediaDetailImageView.setOnClickListener { + launchZoomActivity( + binding.mediaDetailImageView + ) + } + binding.categoryEditButton.setOnClickListener { onCategoryEditButtonClicked() } + binding.depictionsEditButton.setOnClickListener { onDepictionsEditButtonClicked() } + binding.seeMore.setOnClickListener { onSeeMoreClicked() } + binding.mediaDetailAuthor.setOnClickListener { onAuthorViewClicked() } + binding.nominateDeletion.setOnClickListener { onDeleteButtonClicked() } + binding.descriptionEdit.setOnClickListener { onDescriptionEditClicked() } + binding.coordinateEdit.setOnClickListener { onUpdateCoordinatesClicked() } + binding.copyWikicode.setOnClickListener { onCopyWikicodeClicked() } + + binding.fileUsagesComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme( + primary = colorResource(R.color.primaryDarkColor), + surface = colorResource(R.color.main_background_dark), + background = colorResource(R.color.main_background_dark) + ) else lightColorScheme( + primary = colorResource(R.color.primaryColor), + surface = colorResource(R.color.main_background_light), + background = colorResource(R.color.main_background_light) + ) + ) { + + val commonsContainerState by viewModel.commonsContainerState.collectAsState() + val globalContainerState by viewModel.globalContainerState.collectAsState() + + Surface { + Column { + Text( + text = stringResource(R.string.file_usages_container_heading), + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + FileUsagesContainer( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + commonsContainerState = commonsContainerState, + globalContainerState = globalContainerState + ) + } + } + + + } + } + } + + /** + * Gets the height of the frame layout as soon as the view is ready and updates aspect ratio + * of the picture. + */ + view.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + return view + } + + fun launchZoomActivity(view: View) { + val hasPermission: Boolean = hasPermission(requireActivity(), PERMISSIONS_STORAGE) + if (hasPermission) { + launchZoomActivityAfterPermissionCheck(view) + } else { + checkPermissionsAndPerformAction( + requireActivity(), + { + launchZoomActivityAfterPermissionCheck(view) + }, + R.string.storage_permission_title, + R.string.read_storage_permission_rationale, + *PERMISSIONS_STORAGE + ) + } + } + + private fun fetchFileUsages(fileName: String) { + if (viewModel.commonsContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadFileUsagesCommons(fileName) + } + + if (viewModel.globalContainerState.value == MediaDetailViewModel.FileUsagesContainerState.Initial) { + viewModel.loadGlobalFileUsages(fileName) + } + } + + /** + * launch zoom acitivity after permission check + * @param view as ImageView + */ + private fun launchZoomActivityAfterPermissionCheck(view: View) { + if (media!!.imageUrl != null) { + val ctx: Context = view.context + val zoomableIntent = Intent(ctx, ZoomableActivity::class.java) + zoomableIntent.setData(Uri.parse(media!!.imageUrl)) + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.ORIGIN, "MediaDetails" + ) + + val backgroundColor: Int = imageBackgroundColor + if (backgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + zoomableIntent.putExtra( + ZoomableActivity.ZoomableActivityConstants.PHOTO_BACKGROUND_COLOR, + backgroundColor + ) + } + + ctx.startActivity( + zoomableIntent + ) + } + } + + override fun onResume() { + super.onResume() + if (parentFragment != null && requireParentFragment().parentFragment != null) { + // Added a check because, not necessarily, the parent fragment + // will have a parent fragment, say in the case when MediaDetailPagerFragment + // is directly started by the CategoryImagesActivity + if (parentFragment is ContributionsFragment) { + (((parentFragment as ContributionsFragment) + .parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = + View.GONE + } + } + // detail provider is null when fragment is shown in review activity + media = if (detailProvider != null) { + detailProvider!!.getMediaAtPosition(index) + } else { + requireArguments().getParcelable("media") + } + + if (media != null && applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + enableProgressBar() + } + + if (getUserName(requireContext()) != null && media != null && getUserName( + requireContext() + ) == media!!.author + ) { + binding.sendThanks.visibility = View.GONE + } else { + binding.sendThanks.visibility = View.VISIBLE + } + + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (context == null) { + return + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + oldWidthOfImageView = binding.mediaDetailScrollView.width + if (media != null) { + displayMediaDetails() + fetchFileUsages(media?.filename!!) + } + } + } + ) + binding.progressBarEdit.visibility = View.GONE + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + binding.mediaDetailScrollView.viewTreeObserver.addOnGlobalLayoutListener( + object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + /** + * We update the height of the frame layout as the configuration changes. + */ + binding.mediaDetailFrameLayout.post { + frameLayoutHeight = binding.mediaDetailFrameLayout.measuredHeight + updateAspectRatio(binding.mediaDetailScrollView.width) + } + if (binding.mediaDetailScrollView.width != oldWidthOfImageView) { + if (newWidthOfImageView == 0) { + newWidthOfImageView = binding.mediaDetailScrollView.width + updateAspectRatio(newWidthOfImageView) + } + binding.mediaDetailScrollView.viewTreeObserver.removeOnGlobalLayoutListener( + this + ) + } + } + } + ) + // Ensuring correct aspect ratio for landscape mode + if (heightVerifyingBoolean) { + updateAspectRatio(newWidthOfImageView) + heightVerifyingBoolean = false + } else { + updateAspectRatio(oldWidthOfImageView) + heightVerifyingBoolean = true + } + } + + private fun displayMediaDetails() { + setTextFields(media!!) + compositeDisposable.addAll( + mediaDataExtractor.refresh(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { media: Media -> onMediaRefreshed(media) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> updateCategoryList(s!!) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.checkDeletionRequestExists(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { deletionPageExists: Boolean -> onDeletionPageExists(deletionPageExists) }, + { t: Throwable? -> Timber.e(t) }), + mediaDataExtractor.fetchDiscussion(media!!) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { discussion: String -> onDiscussionLoaded(discussion) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + private fun onMediaRefreshed(media: Media) { + media.categories = this.media!!.categories + this.media = media + setTextFields(media) + compositeDisposable.addAll( + mediaDataExtractor.fetchDepictionIdsAndLabels(media) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { idAndCaptions: List -> onDepictionsLoaded(idAndCaptions) }, + { t: Throwable? -> Timber.e(t) }) + ) + // compositeDisposable.add(disposable); + } + + private fun onDiscussionLoaded(discussion: String) { + binding.mediaDetailDisc.text = prettyDiscussion(discussion.trim { it <= ' ' }) + } + + private fun onDeletionPageExists(deletionPageExists: Boolean) { + if (getUserName(requireContext()) == null && getUserName(requireContext()) != media!!.author) { + binding.nominateDeletion.visibility = View.GONE + binding.nominatedDeletionBanner.visibility = View.GONE + } else if (deletionPageExists) { + if (applicationKvStore.getBoolean( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl), false + ) + ) { + applicationKvStore.remove( + String.format(NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl) + ) + binding.progressBarDeletion.visibility = View.GONE + } + binding.nominateDeletion.visibility = View.GONE + + binding.nominatedDeletionBanner.visibility = View.VISIBLE + } else if (!isCategoryImage) { + binding.nominateDeletion.visibility = View.VISIBLE + binding.nominatedDeletionBanner.visibility = View.GONE + } + } + + private fun onDepictionsLoaded(idAndCaptions: List) { + binding.depictsLayout.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + binding.depictionsEditButton.visibility = + if (idAndCaptions.isEmpty()) View.GONE else View.VISIBLE + buildDepictionList(idAndCaptions) + } + + /** + * By clicking on the edit depictions button, it will send user to depict fragment + */ + fun onDepictionsEditButtonClicked() { + binding.mediaDetailDepictionContainer.removeAllViews() + binding.depictionsEditButton.visibility = View.GONE + val depictsFragment: Fragment = DepictsFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Depicts", media) + depictsFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, depictsFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + /** + * The imageSpacer is Basically a transparent overlay for the SimpleDraweeView + * which holds the image to be displayed( moreover this image is out of + * the scroll view ) + * + * + * If the image is sufficiently large i.e. the image height extends the view height, we reduce + * the height and change the width to maintain the aspect ratio, otherwise image takes up the + * total possible width and height is adjusted accordingly. + * + * @param scrollWidth the current width of the scrollView + */ + private fun updateAspectRatio(scrollWidth: Int) { + if (imageInfoCache != null) { + var finalHeight: Int = (scrollWidth * imageInfoCache!!.height) / imageInfoCache!!.width + val params: ViewGroup.LayoutParams = binding.mediaDetailImageView.layoutParams + val spacerParams: ViewGroup.LayoutParams = + binding.mediaDetailImageViewSpacer.layoutParams + params.width = scrollWidth + if (finalHeight > frameLayoutHeight - minimumHeightOfMetadata) { + // Adjust the height and width of image. + + val temp: Int = frameLayoutHeight - minimumHeightOfMetadata + params.width = (scrollWidth * temp) / finalHeight + finalHeight = temp + } + params.height = finalHeight + spacerParams.height = finalHeight + binding.mediaDetailImageView.layoutParams = params + binding.mediaDetailImageViewSpacer.layoutParams = spacerParams + } + } + + private val aspectRatioListener: ControllerListener = + object : BaseControllerListener() { + override fun onIntermediateImageSet(id: String, imageInfo: ImageInfo?) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + + override fun onFinalImageSet( + id: String, + imageInfo: ImageInfo?, + animatable: Animatable? + ) { + imageInfoCache = imageInfo + updateAspectRatio(binding.mediaDetailScrollView.width) + } + } + + /** + * Uses two image sources. + * - low resolution thumbnail is shown initially + * - when the high resolution image is available, it replaces the low resolution image + */ + private fun setupImageView() { + val imageBackgroundColor: Int = imageBackgroundColor + if (imageBackgroundColor != DEFAULT_IMAGE_BACKGROUND_COLOR) { + binding.mediaDetailImageView.setBackgroundColor(imageBackgroundColor) + } + + binding.mediaDetailImageView.hierarchy.setPlaceholderImage(R.drawable.image_placeholder) + binding.mediaDetailImageView.hierarchy.setFailureImage(R.drawable.image_placeholder) + + val controller: DraweeController = Fresco.newDraweeControllerBuilder() + .setLowResImageRequest(ImageRequest.fromUri(if (media != null) media!!.thumbUrl else null)) + .setRetainImageOnFailure(true) + .setImageRequest(ImageRequest.fromUri(if (media != null) media!!.imageUrl else null)) + .setControllerListener(aspectRatioListener) + .setOldController(binding.mediaDetailImageView.controller) + .build() + binding.mediaDetailImageView.controller = controller + } + + private fun updateToDoWarning() { + var toDoMessage = "" + var toDoNeeded = false + var categoriesPresent: Boolean = + if (media!!.categories == null) false else (media!!.categories!!.isNotEmpty()) + + // Check if the presented category is about need of category + if (categoriesPresent) { + for (category: String in media!!.categories!!) { + if (category.lowercase().contains(CATEGORY_NEEDING_CATEGORIES) || + category.lowercase().contains(CATEGORY_UNCATEGORISED) + ) { + categoriesPresent = false + } + break + } + } + if (!categoriesPresent) { + toDoNeeded = true + toDoMessage += getString(R.string.missing_category) + } + if (isWikipediaButtonDisplayed) { + toDoNeeded = true + toDoMessage += if ((toDoMessage.isEmpty())) "" else "\n" + getString(R.string.missing_article) + } + + if (toDoNeeded) { + toDoMessage = getString(R.string.todo_improve) + "\n" + toDoMessage + binding.toDoLayout.visibility = View.VISIBLE + binding.toDoReason.text = toDoMessage + } else { + binding.toDoLayout.visibility = View.GONE + } + } + + override fun onDestroyView() { + if (layoutListener != null && view != null) { + requireView().viewTreeObserver.removeGlobalOnLayoutListener(layoutListener) // old Android was on crack. CRACK IS WHACK + layoutListener = null + } + + compositeDisposable.clear() + + super.onDestroyView() + } + + private fun setTextFields(media: Media) { + setupImageView() + binding.mediaDetailTitle.text = media.displayTitle + binding.mediaDetailDesc.setHtmlText(prettyDescription(media)) + binding.mediaDetailLicense.text = prettyLicense(media) + binding.mediaDetailCoordinates.text = prettyCoordinates(media) + binding.mediaDetailuploadeddate.text = prettyUploadedDate(media) + if (prettyCaption(media) == requireContext().getString(R.string.detail_caption_empty)) { + binding.captionLayout.visibility = View.GONE + } else { + binding.mediaDetailCaption.text = prettyCaption(media) + } + + categoryNames.clear() + categoryNames.addAll(media.categories!!) + + if (media.author == null || media.author == "") { + binding.authorLinearLayout.visibility = View.GONE + } else { + binding.mediaDetailAuthor.text = media.author + } + } + + /** + * Gets new categories from the WikiText and updates it on the UI + * + * @param s WikiText + */ + private fun updateCategoryList(s: String) { + val allCategories: MutableList = ArrayList() + var i: Int = s.indexOf("[[Category:") + while (i != -1) { + val category: String = s.substring(i + 11, s.indexOf("]]", i)) + allCategories.add(category) + i = s.indexOf("]]", i) + i = s.indexOf("[[Category:", i) + } + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + if (sessionManager.isUserLoggedIn) { + binding.categoryEditButton.visibility = View.VISIBLE + } + rebuildCatList(allCategories) + } + + /** + * Updates the categories + */ + fun updateCategories() { + val allCategories: MutableList = ArrayList( + media?.addedCategories!! + ) + media!!.categories = allCategories + if (allCategories.isEmpty()) { + // Stick in a filler element. + allCategories.add(getString(R.string.detail_panel_cats_none)) + } + + rebuildCatList(allCategories) + } + + /** + * Populates media details fragment with depiction list + * @param idAndCaptions + */ + private fun buildDepictionList(idAndCaptions: List) { + binding.mediaDetailDepictionContainer.removeAllViews() + val locale: String = Locale.getDefault().language + for (idAndCaption: IdAndCaptions in idAndCaptions) { + binding.mediaDetailDepictionContainer.addView( + buildDepictLabel( + getDepictionCaption(idAndCaption, locale), + idAndCaption.id, + binding.mediaDetailDepictionContainer + ) + ) + } + } + + private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { + // Check if the Depiction Caption is available in user's locale + // if not then check for english, else show any available. + if (idAndCaption.captions[locale] != null) { + return idAndCaption.captions[locale] + } + if (idAndCaption.captions["en"] != null) { + return idAndCaption.captions["en"] + } + return idAndCaption.captions.values.iterator().next() + } + + private fun onMediaDetailLicenceClicked() { + val url: String? = media!!.licenseUrl + if (!StringUtils.isBlank(url) && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(url)) + } else { + viewUtil.showShortToast(requireActivity(), getString(R.string.null_url)) + } + } + + private fun onMediaDetailCoordinatesClicked() { + if (media!!.coordinates != null && activity != null) { + Utils.handleGeoCoordinates(activity, media!!.coordinates) + } + } + + private fun onCopyWikicodeClicked() { + val data: String = + "[[" + media!!.filename + "|thumb|" + media!!.fallbackDescription + "]]" + Utils.copy("wikiCode", data, context) + Timber.d("Generated wikidata copy code: %s", data) + + Toast.makeText(context, getString(R.string.wikicode_copied), Toast.LENGTH_SHORT) + .show() + } + + /** + * Sends thanks to author if the author is not the user + */ + private fun sendThanksToAuthor() { + val fileName: String? = media!!.filename + if (TextUtils.isEmpty(fileName)) { + Toast.makeText( + context, getString(R.string.error_sending_thanks), + Toast.LENGTH_SHORT + ).show() + return + } + compositeDisposable.add( + reviewHelper.getFirstRevisionOfFile(fileName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { revision: Revision? -> + sendThanks( + requireContext(), revision + ) + } + ) + } + + /** + * Api call for sending thanks to the author when the author is not the user + * and display toast depending on the result + * @param context context + * @param firstRevision the revision id of the image + */ + @SuppressLint("CheckResult", "StringFormatInvalid") + fun sendThanks(context: Context, firstRevision: Revision?) { + showShortToast( + context, + context.getString(R.string.send_thank_toast, media!!.displayTitle) + ) + + if (firstRevision == null) { + return + } + + Observable.defer { + thanksClient.thank( + firstRevision.revisionId + ) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { result: Boolean -> + displayThanksToast( + requireContext(), result + ) + }, + { throwable: Throwable? -> + if (throwable is InvalidLoginTokenException) { + val username: String? = sessionManager.userName + val logoutListener: CommonsApplication.BaseLogoutListener = + CommonsApplication.BaseLogoutListener( + requireActivity(), + requireActivity().getString(R.string.invalid_login_message), + username + ) + + instance.clearApplicationData( + requireActivity(), logoutListener + ) + } else { + Timber.e(throwable) + } + }) + } + + /** + * Method to display toast when api call to thank the author is completed + * @param context context + * @param result true if success, false otherwise + */ + @SuppressLint("StringFormatInvalid") + private fun displayThanksToast(context: Context, result: Boolean) { + val message: String = if (result) { + context.getString( + R.string.send_thank_success_message, + media!!.displayTitle + ) + } else { + context.getString( + R.string.send_thank_failure_message, + media!!.displayTitle + ) + } + + showShortToast(context, message) + } + + fun onCategoryEditButtonClicked() { + binding.progressBarEditCategory.visibility = View.VISIBLE + binding.categoryEditButton.visibility = View.GONE + wikiText + } + + private val wikiText: Unit + /** + * Gets WikiText from the server and send it to catgory editor + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> gotoCategoryEditor(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Opens the category editor + * + * @param s WikiText + */ + private fun gotoCategoryEditor(s: String) { + binding.categoryEditButton.visibility = View.VISIBLE + binding.progressBarEditCategory.visibility = View.GONE + val categoriesFragment: Fragment = UploadCategoriesFragment() + val bundle = Bundle() + bundle.putParcelable("Existing_Categories", media) + bundle.putString("WikiText", s) + categoriesFragment.arguments = bundle + val transaction: FragmentTransaction = childFragmentManager.beginTransaction() + transaction.replace(R.id.mediaDetailFrameLayout, categoriesFragment) + transaction.addToBackStack(null) + transaction.commit() + } + + fun onUpdateCoordinatesClicked() { + goToLocationPickerActivity() + } + + /** + * Start location picker activity with a request code and get the coordinates from the activity. + */ + private fun goToLocationPickerActivity() { + /* + If location is not provided in media this coordinates will act as a placeholder in + location picker activity + */ + var defaultLatitude = 37.773972 + var defaultLongitude: Double = -122.431297 + if (media!!.coordinates != null) { + defaultLatitude = media!!.coordinates!!.latitude + defaultLongitude = media!!.coordinates!!.longitude + } else { + if (locationManager.getLastLocation() != null) { + defaultLatitude = locationManager.getLastLocation()!!.latitude + defaultLongitude = locationManager.getLastLocation()!!.longitude + } else { + val lastLocation: Array? = applicationKvStore.getString( + UploadMediaDetailFragment.LAST_LOCATION, + ("$defaultLatitude,$defaultLongitude") + )?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + + if (lastLocation != null) { + defaultLatitude = lastLocation[0].toDouble() + defaultLongitude = lastLocation[1].toDouble() + } + } + } + + + startActivity( + LocationPicker.IntentBuilder() + .defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, 16.0)) + .activityKey("MediaActivity") + .media(media!!) + .build(requireActivity()) + ) + } + + fun onDescriptionEditClicked() { + binding.progressBarEdit.visibility = View.VISIBLE + binding.descriptionEdit.visibility = View.GONE + descriptionList + } + + private val descriptionList: Unit + /** + * Gets descriptions from wikitext + */ + get() { + compositeDisposable.add( + mediaDataExtractor.getCurrentWikiText( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String? -> extractCaptionDescription(s!!) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * Gets captions and descriptions and merge them according to language code and arranges it in a + * single list. + * Send the list to DescriptionEditActivity + * @param s wikitext + */ + private fun extractCaptionDescription(s: String) { + val descriptions: LinkedHashMap = getDescriptions(s) + val captions: LinkedHashMap = captionsList + + val descriptionAndCaptions: ArrayList = ArrayList() + + if (captions.size >= descriptions.size) { + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + (mapElement.value as String?)!! + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, "", + (mapElement.value as String?)!! + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (!captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + Objects.requireNonNull(descriptions[language]!!), + "" + ) + ) + } + } + } else { + for (mapElement: Map.Entry<*, *> in descriptions.entries) { + val language: String = mapElement.key as String + if (captions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + Objects.requireNonNull(captions[language]!!) + ) + ) + } else { + descriptionAndCaptions.add( + UploadMediaDetail( + language, (mapElement.value as String?)!!, + "" + ) + ) + } + } + for (mapElement: Map.Entry<*, *> in captions.entries) { + val language: String = mapElement.key as String + if (!descriptions.containsKey(language)) { + descriptionAndCaptions.add( + UploadMediaDetail( + language, + "", + Objects.requireNonNull(descriptions[language]!!) + ) + ) + } + } + } + val intent = Intent(requireContext(), DescriptionEditActivity::class.java) + val bundle = Bundle() + bundle.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, descriptionAndCaptions) + bundle.putString(WIKITEXT, s) + bundle.putString( + Prefs.DESCRIPTION_LANGUAGE, + applicationKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "") + ) + bundle.putParcelable("media", media) + intent.putExtras(bundle) + startActivity(intent) + } + + /** + * Filters descriptions from current wikiText and arranges it in LinkedHashmap according to the + * language code + * @param s wikitext + * @return LinkedHashMap,Description> + */ + private fun getDescriptions(s: String): LinkedHashMap { + val pattern: Pattern = Pattern.compile("[dD]escription *=(.*?)\n *\\|", Pattern.DOTALL) + val matcher: Matcher = pattern.matcher(s) + var description: String? = null + if (matcher.find()) { + description = matcher.group() + } + if (description == null) { + return LinkedHashMap() + } + + val descriptionList: LinkedHashMap = LinkedHashMap() + + var count = 0 // number of "{{" + var startCode = 0 + var endCode = 0 + var startDescription = 0 + var endDescription: Int + val allLanguageCodes: HashSet = HashSet( + mutableListOf( + "en", + "es", + "de", + "ja", + "fr", + "ru", + "pt", + "it", + "zh-hans", + "zh-hant", + "ar", + "ko", + "id", + "pl", + "nl", + "fa", + "hi", + "th", + "vi", + "sv", + "uk", + "cs", + "simple", + "hu", + "ro", + "fi", + "el", + "he", + "nb", + "da", + "sr", + "hr", + "ms", + "bg", + "ca", + "tr", + "sk", + "sh", + "bn", + "tl", + "mr", + "ta", + "kk", + "lt", + "az", + "bs", + "sl", + "sq", + "arz", + "zh-yue", + "ka", + "te", + "et", + "lv", + "ml", + "hy", + "uz", + "kn", + "af", + "nn", + "mk", + "gl", + "sw", + "eu", + "ur", + "ky", + "gu", + "bh", + "sco", + "ast", + "is", + "mn", + "be", + "an", + "km", + "si", + "ceb", + "jv", + "eo", + "als", + "ig", + "su", + "be-x-old", + "la", + "my", + "cy", + "ne", + "bar", + "azb", + "mzn", + "as", + "am", + "so", + "pa", + "map-bms", + "scn", + "tg", + "ckb", + "ga", + "lb", + "war", + "zh-min-nan", + "nds", + "fy", + "vec", + "pnb", + "zh-classical", + "lmo", + "tt", + "io", + "ia", + "br", + "hif", + "mg", + "wuu", + "gan", + "ang", + "or", + "oc", + "yi", + "ps", + "tk", + "ba", + "sah", + "fo", + "nap", + "vls", + "sa", + "ce", + "qu", + "ku", + "min", + "bcl", + "ilo", + "ht", + "li", + "wa", + "vo", + "nds-nl", + "pam", + "new", + "mai", + "sn", + "pms", + "eml", + "yo", + "ha", + "gn", + "frr", + "gd", + "hsb", + "cv", + "lo", + "os", + "se", + "cdo", + "sd", + "ksh", + "bat-smg", + "bo", + "nah", + "xmf", + "ace", + "roa-tara", + "hak", + "bjn", + "gv", + "mt", + "pfl", + "szl", + "bpy", + "rue", + "co", + "diq", + "sc", + "rw", + "vep", + "lij", + "kw", + "fur", + "pcd", + "lad", + "tpi", + "ext", + "csb", + "rm", + "kab", + "gom", + "udm", + "mhr", + "glk", + "za", + "pdc", + "om", + "iu", + "nv", + "mi", + "nrm", + "tcy", + "frp", + "myv", + "kbp", + "dsb", + "zu", + "ln", + "mwl", + "fiu-vro", + "tum", + "tet", + "tn", + "pnt", + "stq", + "nov", + "ny", + "xh", + "crh", + "lfn", + "st", + "pap", + "ay", + "zea", + "bxr", + "kl", + "sm", + "ak", + "ve", + "pag", + "nso", + "kaa", + "lez", + "gag", + "kv", + "bm", + "to", + "lbe", + "krc", + "jam", + "ss", + "roa-rup", + "dv", + "ie", + "av", + "cbk-zam", + "chy", + "inh", + "ug", + "ch", + "arc", + "pih", + "mrj", + "kg", + "rmy", + "dty", + "na", + "ts", + "xal", + "wo", + "fj", + "tyv", + "olo", + "ltg", + "ff", + "jbo", + "haw", + "ki", + "chr", + "sg", + "atj", + "sat", + "ady", + "ty", + "lrc", + "ti", + "din", + "gor", + "lg", + "rn", + "bi", + "cu", + "kbd", + "pi", + "cr", + "koi", + "ik", + "mdf", + "bug", + "ee", + "shn", + "tw", + "dz", + "srn", + "ks", + "test", + "en-x-piglatin", + "ab" + ) + ) + var i = 0 + while (i < description.length - 1) { + if (description.startsWith("{{", i)) { + if (count == 0) { + startCode = i + endCode = description.indexOf("|", i) + startDescription = endCode + 1 + if (description.startsWith("1=", endCode + 1)) { + startDescription += 2 + i += 2 + } + } + i++ + count++ + } else if (description.startsWith("}}", i)) { + count-- + if (count == 0) { + endDescription = i + val languageCode: String = description.substring(startCode + 2, endCode) + val languageDescription: String = + description.substring(startDescription, endDescription) + if (allLanguageCodes.contains(languageCode)) { + descriptionList[languageCode] = languageDescription + } + } + i++ + } + i++ + } + return descriptionList + } + + private val captionsList: LinkedHashMap + /** + * Gets list of caption and arranges it in a LinkedHashmap according to the language code + * @return LinkedHashMap,Caption> + */ + get() { + val captionList: LinkedHashMap = + LinkedHashMap() + val captions: Map = media!!.captions + for (map: Map.Entry in captions.entries) { + val language: String = map.key + val languageCaption: String = map.value + captionList[language] = languageCaption + } + return captionList + } + + /** + * Adds caption to the map and updates captions + * @param mediaDetail UploadMediaDetail + * @param updatedCaptions updated captionds + */ + private fun updateCaptions( + mediaDetail: UploadMediaDetail, + updatedCaptions: MutableMap + ) { + updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText + media!!.captions = updatedCaptions + } + + @SuppressLint("StringFormatInvalid") + fun onDeleteButtonClicked() { + if (getUserName(requireContext()) != null && getUserName(requireContext()) == media!!.author) { + val languageAdapter: ArrayAdapter = ArrayAdapter( + requireActivity(), + R.layout.simple_spinner_dropdown_list, reasonList + ) + val spinner = Spinner(activity) + spinner.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + spinner.adapter = languageAdapter + spinner.gravity = 17 + + val dialog: AlertDialog? = showAlertDialog( + requireActivity(), + getString(R.string.nominate_delete), + null, + getString(R.string.about_translate_proceed), + getString(R.string.about_translate_cancel), + { onDeleteClicked(spinner) }, + {}, + spinner, + true + ) + if (isDeleted) { + dialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } else if (getUserName(requireContext()) != null) { + val input = EditText(activity) + input.requestFocus() + val d: AlertDialog? = showAlertDialog( + requireActivity(), + null, + getString(R.string.dialog_box_text_nomination, media!!.displayTitle), + getString(R.string.ok), + getString(R.string.cancel), + { + val reason: String = input.text.toString() + onDeleteClickeddialogtext(reason) + }, + {}, + input, + true + ) + input.addTextChangedListener(object : TextWatcher { + fun handleText() { + val okButton: Button = d!!.getButton(AlertDialog.BUTTON_POSITIVE) + if (input.text.isEmpty() || isDeleted) { + okButton.isEnabled = false + } else { + okButton.isEnabled = true + } + } + + override fun afterTextChanged(arg0: Editable) { + handleText() + } + + override fun beforeTextChanged( + s: CharSequence, + start: Int, + count: Int, + after: Int + ) { + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + } + }) + d!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClicked(spinner: Spinner) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val reason: String = reasonListEnglishMappings[spinner.selectedItemPosition] + val finalReason: String = reason + val resultSingle: Single = reasonBuilder.getReason(media, reason) + .flatMap { + deleteHelper.makeDeletion( + context, media, finalReason + ) + } + resultSingle + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + @SuppressLint("CheckResult") + private fun onDeleteClickeddialogtext(reason: String) { + applicationKvStore.putBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ), true + ) + enableProgressBar() + val resultSingletext: Single = reasonBuilder.getReason(media, reason) + .flatMap { _ -> + deleteHelper.makeDeletion( + context, media, reason + ) + } + resultSingletext + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _ -> + if (applicationKvStore.getBoolean( + String.format( + NOMINATING_FOR_DELETION_MEDIA, media!!.imageUrl + ), false + ) + ) { + applicationKvStore.remove( + String.format( + NOMINATING_FOR_DELETION_MEDIA, + media!!.imageUrl + ) + ) + callback!!.nominatingForDeletion(index) + } + } + } + + private fun onSeeMoreClicked() { + if (binding.nominatedDeletionBanner.visibility == View.VISIBLE && activity != null) { + Utils.handleWebUrl(activity, Uri.parse(media!!.pageTitle.mobileUri)) + } + } + + private fun onAuthorViewClicked() { + if (media == null || media!!.user == null) { + return + } + if (sessionManager.userName == null) { + val userProfileLink: String = BuildConfig.COMMONS_URL + "/wiki/User:" + media!!.user + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(userProfileLink)) + startActivity(browserIntent) + return + } + ProfileActivity.startYourself( + activity, + media!!.user, + sessionManager.userName != media!!.user + ) + } + + /** + * Enable Progress Bar and Update delete button text. + */ + private fun enableProgressBar() { + binding.progressBarDeletion.visibility = View.VISIBLE + binding.nominateDeletion.text = requireContext().getString(R.string.nominate_deletion) + isDeleted = true + } + + private fun rebuildCatList(categories: List) { + binding.mediaDetailCategoryContainer.removeAllViews() + for (category: String in categories) { + binding.mediaDetailCategoryContainer.addView( + buildCatLabel( + sanitise(category), + binding.mediaDetailCategoryContainer + ) + ) + } + } + + //As per issue #1826(see https://github.com/commons-app/apps-android-commons/issues/1826), + // some categories come suffixed with strings prefixed with |. As per the discussion + //that was meant for alphabetical sorting of the categories and can be safely removed. + private fun sanitise(category: String): String { + val indexOfPipe: Int = category.indexOf('|') + if (indexOfPipe != -1) { + //Removed everything after '|' + return category.substring(0, indexOfPipe) + } + return category + } + + /** + * Add view to depictions obtained also tapping on depictions should open the url + */ + private fun buildDepictLabel( + depictionName: String?, + entityId: String, + depictionContainer: LinearLayout + ): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, depictionContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + textView.text = depictionName + item.setOnClickListener { + val intent = Intent( + context, + WikidataItemDetailsActivity::class.java + ) + intent.putExtra("wikidataItemName", depictionName) + intent.putExtra("entityId", entityId) + intent.putExtra("fragment", "MediaDetailFragment") + requireContext().startActivity(intent) + } + return item + } + + private fun buildCatLabel(catName: String, categoryContainer: ViewGroup): View { + val item: View = LayoutInflater.from(context) + .inflate(R.layout.detail_category_item, categoryContainer, false) + val textView: TextView = item.findViewById(R.id.mediaDetailCategoryItemText) + + textView.text = catName + if (getString(R.string.detail_panel_cats_none) != catName) { + textView.setOnClickListener { + // Open Category Details page + val intent = Intent(context, CategoryDetailsActivity::class.java) + intent.putExtra("categoryName", catName) + requireContext().startActivity(intent) + } + } + return item + } + + /** + * Returns captions for media details + * + * @param media object of class media + * @return caption as string + */ + private fun prettyCaption(media: Media): String { + for (caption: String in media.captions.values) { + return if (caption == "") { + getString(R.string.detail_caption_empty) + } else { + caption + } + } + return getString(R.string.detail_caption_empty) + } + + private fun prettyDescription(media: Media): String { + var description: String? = chooseDescription(media) + if (description!!.isNotEmpty()) { + // Remove img tag that sometimes appears as a blue square in the app, + // see https://github.com/commons-app/apps-android-commons/issues/4345 + description = description.replace("[<](/)?img[^>]*[>]".toRegex(), "") + } + return description.ifEmpty { getString(R.string.detail_description_empty) } + } + + private fun chooseDescription(media: Media): String? { + val descriptions: Map = media.descriptions + val multilingualDesc: String? = descriptions[Locale.getDefault().language] + if (multilingualDesc != null) { + return multilingualDesc + } + for (description: String in descriptions.values) { + return description + } + return media.fallbackDescription + } + + private fun prettyDiscussion(discussion: String): String { + return discussion.ifEmpty { getString(R.string.detail_discussion_empty) } + } + + private fun prettyLicense(media: Media): String { + val licenseKey: String? = media.license + Timber.d("Media license is: %s", licenseKey) + if (licenseKey == null || licenseKey == "") { + return getString(R.string.detail_license_empty) + } + return licenseKey + } + + private fun prettyUploadedDate(media: Media): String { + val date: Date? = media.dateUploaded + if (date?.toString() == null || date.toString().isEmpty()) { + return "Uploaded date not available" + } + return getDateStringWithSkeletonPattern(date, "dd MMM yyyy") + } + + /** + * Returns the coordinates nicely formatted. + * + * @return Coordinates as text. + */ + private fun prettyCoordinates(media: Media): String { + if (media.coordinates == null) { + return getString(R.string.media_detail_coordinates_empty) + } + return media.coordinates!!.getPrettyCoordinateString() + } + + override fun updateCategoryDisplay(categories: List?): Boolean { + if (categories == null) { + return false + } else { + rebuildCatList(categories) + return true + } + } + + fun showCaptionAndDescription() { + if (binding.dummyCaptionDescriptionContainer.visibility == View.GONE) { + binding.dummyCaptionDescriptionContainer.visibility = View.VISIBLE + setUpCaptionAndDescriptionLayout() + } else { + binding.dummyCaptionDescriptionContainer.visibility = View.GONE + } + } + + /** + * setUp Caption And Description Layout + */ + private fun setUpCaptionAndDescriptionLayout() { + val captions: List = captions + + if (descriptionHtmlCode == null) { + binding.showCaptionsBinding.pbCircular.visibility = View.VISIBLE + } + + description + val adapter = CaptionListViewAdapter(captions) + binding.showCaptionsBinding.captionListview.adapter = adapter + } + + private val captions: List + /** + * Generate the caption with language + */ + get() { + val captionList: MutableList = + ArrayList() + val captions: Map = media!!.captions + val appLanguageLookUpTable = + AppLanguageLookUpTable(requireContext()) + for (map: Map.Entry in captions.entries) { + val language: String? = appLanguageLookUpTable.getLocalizedName(map.key) + val languageCaption: String = map.value + captionList.add(Caption(language, languageCaption)) + } + + if (captionList.size == 0) { + captionList.add(Caption("", "No Caption")) + } + return captionList + } + + private val description: Unit + get() { + compositeDisposable.add( + mediaDataExtractor.getHtmlOfPage( + Objects.requireNonNull(media?.filename!!) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { s: String -> extractDescription(s) }, + { t: Throwable? -> Timber.e(t) }) + ) + } + + /** + * extract the description from html of imagepage + */ + private fun extractDescription(s: String) { + val descriptionClassName = "" + val start: Int = s.indexOf(descriptionClassName) + descriptionClassName.length + val end: Int = s.indexOf("", start) + descriptionHtmlCode = "" + for (i in start until end) { + descriptionHtmlCode += s.toCharArray()[i] + } + + binding.showCaptionsBinding.descriptionWebview + .loadDataWithBaseURL(null, descriptionHtmlCode!!, "text/html", "utf-8", null) + binding.showCaptionsBinding.pbCircular.visibility = View.GONE + } + + /** + * Handle back event when fragment when showCaptionAndDescriptionContainer is visible + */ + private fun handleBackEvent(view: View) { + view.isFocusableInTouchMode = true + view.requestFocus() + view.setOnKeyListener(object : View.OnKeyListener { + override fun onKey(view: View, keycode: Int, keyEvent: KeyEvent): Boolean { + if (keycode == KeyEvent.KEYCODE_BACK) { + if (binding.dummyCaptionDescriptionContainer.visibility == View.VISIBLE) { + binding.dummyCaptionDescriptionContainer.visibility = + View.GONE + return true + } + } + return false + } + }) + } + + + interface Callback { + fun nominatingForDeletion(index: Int) + } + + /** + * Called when the image background color is changed. + * You should pass a useable color, not a resource id. + * @param color + */ + fun onImageBackgroundChanged(color: Int) { + val currentColor: Int = imageBackgroundColor + if (currentColor == color) { + return + } + + binding.mediaDetailImageView.setBackgroundColor(color) + imageBackgroundColorPref.edit().putInt(IMAGE_BACKGROUND_COLOR, color).apply() + } + + private val imageBackgroundColorPref: SharedPreferences + get() = requireContext().getSharedPreferences( + IMAGE_BACKGROUND_COLOR + media!!.pageId, + Context.MODE_PRIVATE + ) + + private val imageBackgroundColor: Int + get() { + val imageBackgroundColorPref: SharedPreferences = + imageBackgroundColorPref + return imageBackgroundColorPref.getInt( + IMAGE_BACKGROUND_COLOR, + DEFAULT_IMAGE_BACKGROUND_COLOR + ) + } + + companion object { + private const val IMAGE_BACKGROUND_COLOR: String = "image_background_color" + const val DEFAULT_IMAGE_BACKGROUND_COLOR: Int = 0 + + @JvmStatic + fun forMedia( + index: Int, + editable: Boolean, + isCategoryImage: Boolean, + isWikipediaButtonDisplayed: Boolean + ): MediaDetailFragment { + val mf = MediaDetailFragment() + val state = Bundle() + state.putBoolean("editable", editable) + state.putBoolean("isCategoryImage", isCategoryImage) + state.putInt("index", index) + state.putInt("listIndex", 0) + state.putInt("listTop", 0) + state.putBoolean("isWikipediaButtonDisplayed", isWikipediaButtonDisplayed) + mf.arguments = state + + return mf + } + + const val NOMINATING_FOR_DELETION_MEDIA: String = "Nominating for deletion %s" + } +} + +@Composable +fun FileUsagesContainer( + modifier: Modifier = Modifier, + commonsContainerState: MediaDetailViewModel.FileUsagesContainerState, + globalContainerState: MediaDetailViewModel.FileUsagesContainerState, +) { + var isCommonsListExpanded by rememberSaveable { mutableStateOf(true) } + var isOtherWikisListExpanded by rememberSaveable { mutableStateOf(true) } + + val uriHandle = LocalUriHandler.current + + Column(modifier = modifier) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + Text( + text = stringResource(R.string.usages_on_commons_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isCommonsListExpanded = !isCommonsListExpanded + }) { + Icon( + imageVector = if (isCommonsListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isCommonsListExpanded) { + when (commonsContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = commonsContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + modifier = Modifier.clickable { + uriHandle.openUri(usage.link!!) + }, + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + color = Color(0xFF5A6AEC), + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = commonsContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.usages_on_other_wikis_heading), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleSmall + ) + + IconButton(onClick = { + isOtherWikisListExpanded = !isOtherWikisListExpanded + }) { + Icon( + imageVector = if (isOtherWikisListExpanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + + if (isOtherWikisListExpanded) { + when (globalContainerState) { + MediaDetailViewModel.FileUsagesContainerState.Loading -> { + LinearProgressIndicator() + } + + is MediaDetailViewModel.FileUsagesContainerState.Success -> { + + val data = globalContainerState.data + + if (data.isNullOrEmpty()) { + ListItem(headlineContent = { + Text( + text = stringResource(R.string.no_usages_found), + style = MaterialTheme.typography.titleSmall + ) + }) + } else { + data.forEach { usage -> + ListItem( + leadingContent = { + Text( + text = stringResource(R.string.bullet_point), + fontWeight = FontWeight.Bold + ) + }, + headlineContent = { + Text( + text = usage.title, + style = MaterialTheme.typography.titleSmall.copy( + textDecoration = TextDecoration.Underline + ) + ) + }) + } + } + } + + is MediaDetailViewModel.FileUsagesContainerState.Error -> { + ListItem(headlineContent = { + Text( + text = globalContainerState.errorMessage, + color = Color.Red, + style = MaterialTheme.typography.titleSmall + ) + }) + } + + MediaDetailViewModel.FileUsagesContainerState.Initial -> {} + } + } + + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt new file mode 100644 index 0000000000..f02df35c75 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailViewModel.kt @@ -0,0 +1,116 @@ +package fr.free.nrw.commons.media + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import fr.free.nrw.commons.R +import fr.free.nrw.commons.fileusages.FileUsagesUiModel +import fr.free.nrw.commons.fileusages.toUiModel +import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * Show where file is being used on Commons and oher wikis. + */ +class MediaDetailViewModel( + private val applicationContext: Context, + private val okHttpJsonApiClient: OkHttpJsonApiClient +) : + ViewModel() { + + private val _commonsContainerState = + MutableStateFlow(FileUsagesContainerState.Initial) + val commonsContainerState = _commonsContainerState.asStateFlow() + + private val _globalContainerState = + MutableStateFlow(FileUsagesContainerState.Initial) + val globalContainerState = _globalContainerState.asStateFlow() + + fun loadFileUsagesCommons(fileName: String) { + + viewModelScope.launch { + + _commonsContainerState.update { FileUsagesContainerState.Loading } + + try { + val result = + okHttpJsonApiClient.getFileUsagesOnCommons(fileName, 10) + + val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() } + + _commonsContainerState.update { FileUsagesContainerState.Success(data = data) } + + } catch (e: Exception) { + + _commonsContainerState.update { + FileUsagesContainerState.Error( + errorMessage = applicationContext.getString( + R.string.error_while_loading + ) + ) + } + + Timber.e(e, javaClass.simpleName) + + } + } + + } + + fun loadGlobalFileUsages(fileName: String) { + + viewModelScope.launch { + + _globalContainerState.update { FileUsagesContainerState.Loading } + + try { + val result = okHttpJsonApiClient.getGlobalFileUsages(fileName, 10) + + val data = result?.query?.pages?.first()?.fileUsage?.map { it.toUiModel() } + + _globalContainerState.update { FileUsagesContainerState.Success(data = data) } + + } catch (e: Exception) { + _globalContainerState.update { + FileUsagesContainerState.Error( + errorMessage = applicationContext.getString( + R.string.error_while_loading + ) + ) + } + + Timber.e(e, javaClass.simpleName) + + } + } + + } + + sealed class FileUsagesContainerState { + object Initial : FileUsagesContainerState() + object Loading : FileUsagesContainerState() + data class Success(val data: List?) : FileUsagesContainerState() + data class Error(val errorMessage: String) : FileUsagesContainerState() + } + + class MediaDetailViewModelProviderFactory + @Inject constructor( + private val okHttpJsonApiClient: OkHttpJsonApiClient, + private val applicationContext: Context + ) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MediaDetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MediaDetailViewModel(applicationContext, okHttpJsonApiClient) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt index c3ae11b949..71ea1d6927 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt @@ -2,8 +2,11 @@ package fr.free.nrw.commons.mwapi import android.text.TextUtils import com.google.gson.Gson +import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.campaigns.CampaignResponseDTO import fr.free.nrw.commons.explore.depictions.DepictsClient +import fr.free.nrw.commons.fileusages.FileUsagesResponse +import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.nearby.model.ItemsClass @@ -20,6 +23,8 @@ import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour import fr.free.nrw.commons.wikidata.model.GetWikidataEditCountResponse import io.reactivex.Observable import io.reactivex.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient @@ -50,8 +55,10 @@ class OkHttpJsonApiClient @Inject constructor( ): Observable { val fetchLeaderboardUrlTemplate = wikiMediaToolforgeUrl.toString() + LeaderboardConstants.LEADERBOARD_END_POINT - val url = String.format(Locale.ENGLISH, - fetchLeaderboardUrlTemplate, userName, duration, category, limit, offset) + val url = String.format( + Locale.ENGLISH, + fetchLeaderboardUrlTemplate, userName, duration, category, limit, offset + ) val urlBuilder: HttpUrl.Builder = url.toHttpUrlOrNull()!!.newBuilder() .addQueryParameter("user", userName) .addQueryParameter("duration", duration) @@ -80,6 +87,80 @@ class OkHttpJsonApiClient @Inject constructor( }) } + /** + * Show where file is being used on Commons. + */ + suspend fun getFileUsagesOnCommons( + fileName: String?, + pageSize: Int + ): FileUsagesResponse? { + return withContext(Dispatchers.IO) { + + return@withContext try { + + val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder() + urlBuilder.addQueryParameter("prop", "fileusage") + urlBuilder.addQueryParameter("titles", fileName) + urlBuilder.addQueryParameter("fulimit", pageSize.toString()) + + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + gson.fromJson( + json, + FileUsagesResponse::class.java + ) + } else null + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + + /** + * Show where file is being used on non-Commons wikis, typically the Wikipedias in various languages. + */ + suspend fun getGlobalFileUsages( + fileName: String?, + pageSize: Int + ): GlobalFileUsagesResponse? { + + return withContext(Dispatchers.IO) { + + return@withContext try { + + val urlBuilder = BuildConfig.FILE_USAGES_BASE_URL.toHttpUrlOrNull()!!.newBuilder() + urlBuilder.addQueryParameter("prop", "globalusage") + urlBuilder.addQueryParameter("titles", fileName) + urlBuilder.addQueryParameter("gulimit", pageSize.toString()) + + Timber.i("Url %s", urlBuilder.toString()) + val request: Request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + val response: Response = okHttpClient.newCall(request).execute() + if (response.body != null && response.isSuccessful) { + val json: String = response.body!!.string() + + gson.fromJson( + json, + GlobalFileUsagesResponse::class.java + ) + } else null + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + fun setAvatar(username: String?, avatar: String?): Single { val urlTemplate = wikiMediaToolforgeUrl .toString() + LeaderboardConstants.UPDATE_AVATAR_END_POINT diff --git a/app/src/main/res/layout/fragment_media_detail.xml b/app/src/main/res/layout/fragment_media_detail.xml index 3c063945dc..7ce90d19e6 100644 --- a/app/src/main/res/layout/fragment_media_detail.xml +++ b/app/src/main/res/layout/fragment_media_detail.xml @@ -456,6 +456,11 @@ android:layout_height="match_parent" /> + +