Improve credit line in image list (#6295)
Some checks are pending
Android CI / Run tests and generate APK (push) Waiting to run

- When author is not uploader, show both.
- When failing to parse author from HTML, use structured data.
This commit is contained in:
Yusuke Matsubara 2025-04-23 23:23:09 +09:00 committed by GitHub
parent 30762971db
commit 329a68216e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 363 additions and 81 deletions

View file

@ -333,6 +333,7 @@ android {
buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\"" buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\"" buildConfigField "String", "DEPICTS_PROPERTY", "\"P180\""
buildConfigField "String", "CREATOR_PROPERTY", "\"P170\""
dimension 'tier' dimension 'tier'
} }
@ -370,6 +371,7 @@ android {
buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\"" buildConfigField "String", "TEST_USERNAME", "\"" + getTestUserName() + "\""
buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\"" buildConfigField "String", "TEST_PASSWORD", "\"" + getTestPassword() + "\""
buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\"" buildConfigField "String", "DEPICTS_PROPERTY", "\"P245962\""
buildConfigField "String", "CREATOR_PROPERTY", "\"P253075\""
dimension 'tier' dimension 'tier'
} }
} }

View file

@ -53,6 +53,7 @@ class Media constructor(
*/ */
var author: String? = null, var author: String? = null,
var user: String? = null, var user: String? = null,
var creatorName: String? = null,
/** /**
* Gets the categories the file falls under. * Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings * @return file categories as an ArrayList of Strings
@ -66,6 +67,7 @@ class Media constructor(
var captions: Map<String, String> = emptyMap(), var captions: Map<String, String> = emptyMap(),
var descriptions: Map<String, String> = emptyMap(), var descriptions: Map<String, String> = emptyMap(),
var depictionIds: List<String> = emptyList(), var depictionIds: List<String> = emptyList(),
var creatorIds: List<String> = emptyList(),
/** /**
* This field was added to find non-hidden categories * This field was added to find non-hidden categories
* Stores the mapping of category title to hidden attribute * Stores the mapping of category title to hidden attribute
@ -130,6 +132,7 @@ class Media constructor(
* returns user * returns user
* @return Author or User * @return Author or User
*/ */
@Deprecated("Use user for uploader username. Use attributedAuthor() for attribution. Note that the uploader may not be the creator/author.")
fun getAuthorOrUser(): String? { fun getAuthorOrUser(): String? {
return if (!author.isNullOrEmpty()) { return if (!author.isNullOrEmpty()) {
author author
@ -138,6 +141,19 @@ class Media constructor(
} }
} }
/**
* Returns author if it's not null or empty, otherwise
* returns creator name
* @return name of author or creator
*/
fun getAttributedAuthor(): String? {
return if (!author.isNullOrEmpty()) {
author
} else{
creatorName
}
}
/** /**
* Gets media display title * Gets media display title
* @return Media title * @return Media title

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons package fr.free.nrw.commons
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import fr.free.nrw.commons.media.IdAndCaptions import fr.free.nrw.commons.media.IdAndLabels
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.PAGE_ID_PREFIX import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import io.reactivex.Single import io.reactivex.Single
@ -23,13 +23,23 @@ class MediaDataExtractor
private val mediaClient: MediaClient, private val mediaClient: MediaClient,
) { ) {
fun fetchDepictionIdsAndLabels(media: Media) = fun fetchDepictionIdsAndLabels(media: Media) =
mediaClient mediaClient
.getEntities(media.depictionIds) .getEntities(media.depictionIds)
.map { .map {
it it
.entities() .entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}.map { it.map { (key, value) -> IdAndCaptions(key, value) } } }.map { it.map { (key, value) -> IdAndLabels(key, value) } }
.onErrorReturn { emptyList() }
fun fetchCreatorIdsAndLabels(media: Media) =
mediaClient
.getEntities(media.creatorIds)
.map {
it
.entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}.map { it.map { (key, value) -> IdAndLabels(key, value) } }
.onErrorReturn { emptyList() } .onErrorReturn { emptyList() }
fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)

View file

@ -8,23 +8,29 @@ import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest import com.facebook.imagepipeline.request.ImageRequest
import com.facebook.imagepipeline.request.ImageRequestBuilder import com.facebook.imagepipeline.request.ImageRequestBuilder
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.utils.MediaAttributionUtil
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.LayoutContributionBinding import fr.free.nrw.commons.databinding.LayoutContributionBinding
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.io.File import java.io.File
class ContributionViewHolder internal constructor( class ContributionViewHolder internal constructor(
private val parent: View, private val callback: ContributionsListAdapter.Callback, parent: View,
private val mediaClient: MediaClient private val callback: ContributionsListAdapter.Callback,
private val compositeDisposable: CompositeDisposable,
private val mediaClient: MediaClient,
private val mediaDataExtractor: MediaDataExtractor
) : RecyclerView.ViewHolder(parent) { ) : RecyclerView.ViewHolder(parent) {
var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent) var binding: LayoutContributionBinding = LayoutContributionBinding.bind(parent)
private var position = 0 private var position = 0
private var contribution: Contribution? = null private var contribution: Contribution? = null
private val compositeDisposable = CompositeDisposable()
private var isWikipediaButtonDisplayed = false private var isWikipediaButtonDisplayed = false
private val pausingPopUp: AlertDialog private val pausingPopUp: AlertDialog
var imageRequest: ImageRequest? = null var imageRequest: ImageRequest? = null
@ -54,7 +60,7 @@ an upload might take a dozen seconds. */
this.contribution = contribution this.contribution = contribution
this.position = position this.position = position
binding.contributionTitle.text = contribution.media.mostRelevantCaption binding.contributionTitle.text = contribution.media.mostRelevantCaption
binding.authorView.text = contribution.media.getAuthorOrUser() setAuthorText(contribution.media)
//Removes flicker of loading image. //Removes flicker of loading image.
binding.contributionImage.hierarchy.fadeDuration = 0 binding.contributionImage.hierarchy.fadeDuration = 0
@ -93,6 +99,30 @@ an upload might take a dozen seconds. */
checkIfMediaExistsOnWikipediaPage(contribution) checkIfMediaExistsOnWikipediaPage(contribution)
} }
fun updateAttribution() {
if (contribution != null) {
val media = contribution!!.media
if (!media.getAttributedAuthor().isNullOrEmpty()) {
return
}
compositeDisposable.addAll(
mediaDataExtractor.fetchCreatorIdsAndLabels(media)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ idAndLabels ->
media.creatorName = MediaAttributionUtil.getCreatorName(idAndLabels)
setAuthorText(media)
},
{ t: Throwable? -> Timber.e(t) })
)
}
}
private fun setAuthorText(media: Media) {
binding.authorView.text = MediaAttributionUtil.getTagLine(media, itemView.context)
}
/** /**
* Checks if a media exists on the corresponding Wikipedia article Currently the check is made * Checks if a media exists on the corresponding Wikipedia article Currently the check is made
* for the device's current language Wikipedia * for the device's current language Wikipedia

View file

@ -4,21 +4,26 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import io.reactivex.disposables.CompositeDisposable
/** /**
* Represents The View Adapter for the List of Contributions * Represents The View Adapter for the List of Contributions
*/ */
class ContributionsListAdapter internal constructor( class ContributionsListAdapter internal constructor(
private val callback: Callback, private val callback: Callback,
private val mediaClient: MediaClient private val mediaClient: MediaClient,
private val mediaDataExtractor: MediaDataExtractor,
private val compositeDisposable: CompositeDisposable
) : PagedListAdapter<Contribution, ContributionViewHolder>(DIFF_CALLBACK) { ) : PagedListAdapter<Contribution, ContributionViewHolder>(DIFF_CALLBACK) {
/** /**
* Initializes the view holder with contribution data * Initializes the view holder with contribution data
*/ */
override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) { override fun onBindViewHolder(holder: ContributionViewHolder, position: Int) {
holder.init(position, getItem(position)) holder.init(position, getItem(position))
holder.updateAttribution()
} }
fun getContributionForPosition(position: Int): Contribution? { fun getContributionForPosition(position: Int): Contribution? {
@ -36,7 +41,7 @@ class ContributionsListAdapter internal constructor(
val viewHolder = ContributionViewHolder( val viewHolder = ContributionViewHolder(
LayoutInflater.from(parent.context) LayoutInflater.from(parent.context)
.inflate(R.layout.layout_contribution, parent, false), .inflate(R.layout.layout_contribution, parent, false),
callback, mediaClient callback, compositeDisposable, mediaClient, mediaDataExtractor
) )
return viewHolder return viewHolder
} }

View file

@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
@ -63,6 +64,10 @@ class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsL
@Inject @Inject
var mediaClient: MediaClient? = null var mediaClient: MediaClient? = null
@JvmField
@Inject
var mediaDataExtractor: MediaDataExtractor? = null
@JvmField @JvmField
@Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE) @Named(NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_WIKI_SITE)
@Inject @Inject
@ -231,7 +236,7 @@ class ContributionsListFragment : CommonsDaggerSupportFragment(), ContributionsL
} }
private fun initAdapter() { private fun initAdapter() {
adapter = ContributionsListAdapter(this, mediaClient!!) adapter = ContributionsListAdapter(this, mediaClient!!, mediaDataExtractor!!, compositeDisposable)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -18,6 +18,12 @@ import javax.inject.Inject
class MediaConverter class MediaConverter
@Inject @Inject
constructor() { constructor() {
/**
* Creating Media object from MWQueryPage.
*
* @param page response from the API
* @return Media object
*/
fun convert( fun convert(
page: MwQueryPage, page: MwQueryPage,
entity: Entities.Entity, entity: Entities.Entity,
@ -40,24 +46,17 @@ class MediaConverter
metadata.prefixedLicenseUrl, metadata.prefixedLicenseUrl,
getAuthor(metadata), getAuthor(metadata),
imageInfo.getUser(), imageInfo.getUser(),
null,
MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()), MediaDataExtractorUtil.extractCategoriesFromList(metadata.categories()),
metadata.latLng, metadata.latLng,
entity.labels().mapValues { it.value.value() }, entity.labels().mapValues { it.value.value() },
entity.descriptions().mapValues { it.value.value() }, entity.descriptions().mapValues { it.value.value() },
entity.depictionIds(), entity.depictionIds(),
entity.creatorIds(),
myMap, myMap,
) )
} }
/**
* Creating Media object from MWQueryPage.
* Earlier only basic details were set for the media object but going forward,
* a full media object(with categories, descriptions, coordinates etc) can be constructed using this method
*
* @param page response from the API
* @return Media object
*/
private fun safeParseDate(dateStr: String): Date? = private fun safeParseDate(dateStr: String): Date? =
try { try {
CommonsDateUtil.getMediaSimpleDateFormat().parse(dateStr) CommonsDateUtil.getMediaSimpleDateFormat().parse(dateStr)
@ -66,24 +65,32 @@ class MediaConverter
} }
/** /**
* This method extracts the Commons Username from the artist HTML information * This method extracts the Commons Username from the artist HTML information.
* When the HTML is in customized formatting, it may fail to parse and return null.
* @param metadata * @param metadata
* @return * @return
*/ */
private fun getAuthor(metadata: ExtMetadata): String? { private fun getAuthor(metadata: ExtMetadata): String? {
return try { val authorHtml = metadata.artist()
val authorHtml = metadata.artist() val anchorStartTagTerminalString = "\">"
val anchorStartTagTerminalChars = "\">" val anchorCloseTag = "</a>"
val anchorCloseTag = "</a>"
return authorHtml.substring( return if (!authorHtml.contains("<") && !authorHtml.contains(">") ) {
authorHtml.indexOf(anchorStartTagTerminalChars) + authorHtml.trim()
anchorStartTagTerminalChars } else if (!authorHtml.contains(anchorStartTagTerminalString) || !authorHtml.endsWith(anchorCloseTag)) {
.length, null
} else {
val authorText = authorHtml.substring(
authorHtml.indexOf(anchorStartTagTerminalString) +
anchorStartTagTerminalString.length,
authorHtml.indexOf(anchorCloseTag), authorHtml.indexOf(anchorCloseTag),
) )
} catch (ex: java.lang.Exception) { if (authorText.contains("<") || authorText.contains(">")) {
"" null
} else {
authorText
}
} }
} }
} }
@ -92,6 +99,10 @@ private fun Entities.Entity.depictionIds() =
this[WikidataProperties.DEPICTS]?.mapNotNull { (it.mainSnak.dataValue as? DataValue.EntityId)?.value?.id } this[WikidataProperties.DEPICTS]?.mapNotNull { (it.mainSnak.dataValue as? DataValue.EntityId)?.value?.id }
?: emptyList() ?: emptyList()
private fun Entities.Entity.creatorIds() =
this[WikidataProperties.CREATOR]?.mapNotNull { (it.mainSnak.dataValue as? DataValue.EntityId)?.value?.id }
?: emptyList()
private val ExtMetadata.prefixedLicenseUrl: String private val ExtMetadata.prefixedLicenseUrl: String
get() = get() =
licenseUrl().let { licenseUrl().let {

View file

@ -4,16 +4,18 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryImagesCallback import fr.free.nrw.commons.category.CategoryImagesCallback
import fr.free.nrw.commons.explore.paging.BasePagingFragment import fr.free.nrw.commons.explore.paging.BasePagingFragment
import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider import fr.free.nrw.commons.media.MediaDetailPagerFragment.MediaDetailProvider
import javax.inject.Inject
abstract class PageableMediaFragment : abstract class PageableMediaFragment :
BasePagingFragment<Media>(), BasePagingFragment<Media>(),
MediaDetailProvider { MediaDetailProvider {
override val pagedListAdapter by lazy { override val pagedListAdapter by lazy {
PagedMediaAdapter(categoryImagesCallback::onMediaClicked) PagedMediaAdapter(categoryImagesCallback::onMediaClicked, mediaDataExtractor)
} }
override val errorTextId: Int = R.string.error_loading_images override val errorTextId: Int = R.string.error_loading_images
@ -22,6 +24,9 @@ abstract class PageableMediaFragment :
lateinit var categoryImagesCallback: CategoryImagesCallback lateinit var categoryImagesCallback: CategoryImagesCallback
@Inject
lateinit var mediaDataExtractor: MediaDataExtractor
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
if (parentFragment != null) { if (parentFragment != null) {

View file

@ -5,13 +5,22 @@ import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.utils.MediaAttributionUtil
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.LayoutCategoryImagesBinding import fr.free.nrw.commons.databinding.LayoutCategoryImagesBinding
import fr.free.nrw.commons.explore.paging.BaseViewHolder import fr.free.nrw.commons.explore.paging.BaseViewHolder
import fr.free.nrw.commons.explore.paging.inflate import fr.free.nrw.commons.explore.paging.inflate
import fr.free.nrw.commons.media.IdAndLabels
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
class PagedMediaAdapter( class PagedMediaAdapter(
private val onImageClicked: (Int) -> Unit, private val onImageClicked: (Int) -> Unit,
private val mediaDataExtractor: MediaDataExtractor,
private val compositeDisposable: CompositeDisposable = CompositeDisposable()
) : PagedListAdapter<Media, SearchImagesViewHolder>( ) : PagedListAdapter<Media, SearchImagesViewHolder>(
object : DiffUtil.ItemCallback<Media>() { object : DiffUtil.ItemCallback<Media>() {
override fun areItemsTheSame( override fun areItemsTheSame(
@ -25,6 +34,7 @@ class PagedMediaAdapter(
) = oldItem.pageId == newItem.pageId ) = oldItem.pageId == newItem.pageId
}, },
) { ) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int, viewType: Int,
@ -37,7 +47,24 @@ class PagedMediaAdapter(
holder: SearchImagesViewHolder, holder: SearchImagesViewHolder,
position: Int, position: Int,
) { ) {
holder.bind(getItem(position)!! to position) val media = getItem(position) ?: return
holder.bind(media to position)
if (!media.getAttributedAuthor().isNullOrEmpty()) {
return
}
compositeDisposable.addAll(
mediaDataExtractor.fetchCreatorIdsAndLabels(media)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ idAndLabels ->
media.creatorName = MediaAttributionUtil.getCreatorName(idAndLabels);
holder.setAuthorText(media)
},
{ t: Throwable? -> Timber.e(t) })
)
} }
} }
@ -52,7 +79,10 @@ class SearchImagesViewHolder(
binding.categoryImageView.setOnClickListener { onImageClicked(item.second) } binding.categoryImageView.setOnClickListener { onImageClicked(item.second) }
binding.categoryImageTitle.text = media.mostRelevantCaption binding.categoryImageTitle.text = media.mostRelevantCaption
binding.categoryImageView.setImageURI(media.thumbUrl) binding.categoryImageView.setImageURI(media.thumbUrl)
binding.categoryImageAuthor.text = setAuthorText(media)
containerView.context.getString(R.string.image_uploaded_by, media.getAuthorOrUser()) }
fun setAuthorText(media: Media) {
binding.categoryImageAuthor.text = MediaAttributionUtil.getTagLine(media, containerView.context)
} }
} }

View file

@ -1,6 +0,0 @@
package fr.free.nrw.commons.media
data class IdAndCaptions(
val id: String,
val captions: Map<String, String>,
)

View file

@ -0,0 +1,18 @@
package fr.free.nrw.commons.media
data class IdAndLabels(
val id: String,
val labels: Map<String, String>,
) {
// if a label is available in user's locale, return it
// if not then check for english, else show any available.
fun getLocalizedLabel(locale: String): String? {
if (labels[locale] != null) {
return labels[locale]
}
if (labels["en"] != null) {
return labels["en"]
}
return labels.values.firstOrNull() ?: id
}
}

View file

@ -16,7 +16,6 @@ import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
@ -622,10 +621,9 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ idAndCaptions: List<IdAndCaptions> -> onDepictionsLoaded(idAndCaptions) }, { IdAndLabels: List<IdAndLabels> -> onDepictionsLoaded(IdAndLabels) },
{ t: Throwable? -> Timber.e(t) }) { t: Throwable? -> Timber.e(t) })
) )
// compositeDisposable.add(disposable);
} }
private fun onDiscussionLoaded(discussion: String) { private fun onDiscussionLoaded(discussion: String) {
@ -655,10 +653,10 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
} }
} }
private fun onDepictionsLoaded(idAndCaptions: List<IdAndCaptions>) { private fun onDepictionsLoaded(IdAndLabels: List<IdAndLabels>) {
binding.depictsLayout.visibility = View.VISIBLE binding.depictsLayout.visibility = View.VISIBLE
binding.depictionsEditButton.visibility = View.VISIBLE binding.depictionsEditButton.visibility = View.VISIBLE
buildDepictionList(idAndCaptions) buildDepictionList(IdAndLabels)
} }
/** /**
@ -863,26 +861,26 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
/** /**
* Populates media details fragment with depiction list * Populates media details fragment with depiction list
* @param idAndCaptions * @param IdAndLabels
*/ */
private fun buildDepictionList(idAndCaptions: List<IdAndCaptions>) { private fun buildDepictionList(IdAndLabels: List<IdAndLabels>) {
binding.mediaDetailDepictionContainer.removeAllViews() binding.mediaDetailDepictionContainer.removeAllViews()
// Create a mutable list from the original list // Create a mutable list from the original list
val mutableIdAndCaptions = idAndCaptions.toMutableList() val mutableIdAndLabels = IdAndLabels.toMutableList()
if (mutableIdAndCaptions.isEmpty()) { if (mutableIdAndLabels.isEmpty()) {
// Create a placeholder IdAndCaptions object and add it to the list // Create a placeholder IdAndLabels object and add it to the list
mutableIdAndCaptions.add( mutableIdAndLabels.add(
IdAndCaptions( IdAndLabels(
id = media?.pageId ?: "", // Use an empty string if media?.pageId is null id = media?.pageId ?: "", // Use an empty string if media?.pageId is null
captions = mapOf(Locale.getDefault().language to getString(R.string.detail_panel_cats_none)) // Create a Map with the language as the key and the message as the value labels = mapOf(Locale.getDefault().language to getString(R.string.detail_panel_cats_none)) // Create a Map with the language as the key and the message as the value
) )
) )
} }
val locale: String = Locale.getDefault().language val locale: String = Locale.getDefault().language
for (idAndCaption: IdAndCaptions in mutableIdAndCaptions) { for (idAndCaption: IdAndLabels in mutableIdAndLabels) {
binding.mediaDetailDepictionContainer.addView( binding.mediaDetailDepictionContainer.addView(
buildDepictLabel( buildDepictLabel(
getDepictionCaption(idAndCaption, locale), getDepictionCaption(idAndCaption, locale),
@ -894,16 +892,16 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
} }
private fun getDepictionCaption(idAndCaption: IdAndCaptions, locale: String): String? { private fun getDepictionCaption(idAndCaption: IdAndLabels, locale: String): String? {
// Check if the Depiction Caption is available in user's locale // Check if the Depiction Caption is available in user's locale
// if not then check for english, else show any available. // if not then check for english, else show any available.
if (idAndCaption.captions[locale] != null) { if (idAndCaption.labels[locale] != null) {
return idAndCaption.captions[locale] return idAndCaption.labels[locale]
} }
if (idAndCaption.captions["en"] != null) { if (idAndCaption.labels["en"] != null) {
return idAndCaption.captions["en"] return idAndCaption.labels["en"]
} }
return idAndCaption.captions.values.iterator().next() return idAndCaption.labels.values.iterator().next()
} }
private fun onMediaDetailLicenceClicked() { private fun onMediaDetailLicenceClicked() {

View file

@ -0,0 +1,39 @@
package fr.free.nrw.commons.utils
import android.content.Context
import android.icu.text.ListFormatter
import android.os.Build
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.media.IdAndLabels
import java.util.Locale
object MediaAttributionUtil {
fun getTagLine(media: Media, context: Context): String {
val uploader = media.user
val author = media.getAttributedAuthor()
return if (author.isNullOrEmpty()) {
context.getString(R.string.image_uploaded_by, uploader)
} else if (author == uploader) {
context.getString(R.string.image_tag_line_created_and_uploaded_by, author)
} else {
context.getString(
R.string.image_tag_line_created_by_and_uploaded_by,
author,
uploader
)
}
}
fun getCreatorName(idAndLabels: List<IdAndLabels>): String? {
val locale = Locale.getDefault()
val names = idAndLabels.map{ x -> x.getLocalizedLabel(locale.language)}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val formatter = ListFormatter.getInstance(locale)
return formatter.format(names)
} else {
return names.joinToString(", ")
}
}
}

View file

@ -1,9 +1,8 @@
package fr.free.nrw.commons.utils package fr.free.nrw.commons.utils
import android.os.Build
import android.text.Html
import android.text.Spanned import android.text.Spanned
import android.text.SpannedString import android.text.SpannedString
import androidx.core.text.HtmlCompat
object StringUtil { object StringUtil {
@ -26,12 +25,6 @@ object StringUtil {
.replace("&#8207;", "\u200F") .replace("&#8207;", "\u200F")
.replace("&amp;", "&") .replace("&amp;", "&")
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return HtmlCompat.fromHtml(processedSource, HtmlCompat.FROM_HTML_MODE_LEGACY)
Html.fromHtml(processedSource, Html.FROM_HTML_MODE_LEGACY)
} else {
//noinspection deprecation
@Suppress("DEPRECATION")
Html.fromHtml(processedSource)
}
} }
} }

View file

@ -7,6 +7,7 @@ enum class WikidataProperties(
) { ) {
IMAGE("P18"), IMAGE("P18"),
DEPICTS(BuildConfig.DEPICTS_PROPERTY), DEPICTS(BuildConfig.DEPICTS_PROPERTY),
CREATOR(BuildConfig.CREATOR_PROPERTY),
COMMONS_CATEGORY("P373"), COMMONS_CATEGORY("P373"),
INSTANCE_OF("P31"), INSTANCE_OF("P31"),
MEDIA_LEGENDS("P2096"), MEDIA_LEGENDS("P2096"),

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.wikidata.model.gallery package fr.free.nrw.commons.wikidata.model.gallery
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import org.apache.commons.lang3.StringUtils
class ExtMetadata { class ExtMetadata {
@SerializedName("DateTime") private val dateTime: Values? = null @SerializedName("DateTime") private val dateTime: Values? = null

View file

@ -873,4 +873,6 @@ Upload your first media by tapping on the add button.</string>
<string name="show_in_explore">Show in Explore</string> <string name="show_in_explore">Show in Explore</string>
<string name="show_in_nearby">Show in Nearby</string> <string name="show_in_nearby">Show in Nearby</string>
<string name="image_tag_line_created_and_uploaded_by">Created and uploaded by: %1$s</string>
<string name="image_tag_line_created_by_and_uploaded_by">Created by %1$s and uploaded by %2$s</string>
</resources> </resources>

View file

@ -50,6 +50,7 @@ fun media(
licenseUrl: String? = "licenseUrl", licenseUrl: String? = "licenseUrl",
author: String? = "creator", author: String? = "creator",
user: String? = "user", user: String? = "user",
creatorName: String? = null,
pageId: String = "pageId", pageId: String = "pageId",
categories: List<String>? = listOf("categories"), categories: List<String>? = listOf("categories"),
coordinates: LatLng? = LatLng(0.0, 0.0, 0.0f), coordinates: LatLng? = LatLng(0.0, 0.0, 0.0f),
@ -67,6 +68,7 @@ fun media(
licenseUrl, licenseUrl,
author, author,
user, user,
creatorName,
categories, categories,
coordinates, coordinates,
captions, captions,

View file

@ -8,6 +8,7 @@ import androidx.test.core.app.ApplicationProvider
import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.soloader.SoLoader import com.facebook.soloader.SoLoader
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.MediaDataExtractor
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.TestUtility.setFinalStatic import fr.free.nrw.commons.TestUtility.setFinalStatic
@ -46,6 +47,9 @@ class ContributionViewHolderUnitTests {
@Mock @Mock
private lateinit var mediaClient: MediaClient private lateinit var mediaClient: MediaClient
@Mock
private lateinit var mediaDataExtractor: MediaDataExtractor
@Mock @Mock
private lateinit var uri: Uri private lateinit var uri: Uri
@ -66,8 +70,9 @@ class ContributionViewHolderUnitTests {
SoLoader.setInTestMode() SoLoader.setInTestMode()
Fresco.initialize(ApplicationProvider.getApplicationContext()) Fresco.initialize(ApplicationProvider.getApplicationContext())
activity = Robolectric.buildActivity(ProfileActivity::class.java).create().get() activity = Robolectric.buildActivity(ProfileActivity::class.java).create().get()
compositeDisposable = CompositeDisposable()
parent = LayoutInflater.from(activity).inflate(R.layout.layout_contribution, null) parent = LayoutInflater.from(activity).inflate(R.layout.layout_contribution, null)
contributionViewHolder = ContributionViewHolder(parent, callback, mediaClient) contributionViewHolder = ContributionViewHolder(parent, callback, compositeDisposable, mediaClient, mediaDataExtractor)
bindind = LayoutContributionBinding.bind(parent) bindind = LayoutContributionBinding.bind(parent)

View file

@ -10,6 +10,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito import org.mockito.Mockito
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
@ -42,23 +43,61 @@ class MediaConverterTest {
@Test @Test
fun testConvertIfThumbUrlBlank() { fun testConvertIfThumbUrlBlank() {
Mockito.`when`(imageInfo.getMetadata()).thenReturn(metadata) `when`(imageInfo.getMetadata()).thenReturn(metadata)
Mockito.`when`(imageInfo.getThumbUrl()).thenReturn("") `when`(imageInfo.getThumbUrl()).thenReturn("")
Mockito.`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl")
Mockito.`when`(imageInfo.getMetadata()?.licenseUrl()).thenReturn("licenseUrl") `when`(metadata.licenseUrl()).thenReturn("licenseUrl")
Mockito.`when`(imageInfo.getMetadata()?.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss")
`when`(metadata.artist()).thenReturn("Foo Bar")
media = mediaConverter.convert(page, entity, imageInfo) media = mediaConverter.convert(page, entity, imageInfo)
assertEquals(media.thumbUrl, media.imageUrl, "originalUrl") assertEquals(media.thumbUrl, media.imageUrl, "originalUrl")
} }
@Test @Test
fun testConvertIfThumbUrlNotBlank() { fun testConvertIfThumbUrlNotBlank() {
Mockito.`when`(imageInfo.getMetadata()).thenReturn(metadata) `when`(imageInfo.getMetadata()).thenReturn(metadata)
Mockito.`when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl") `when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl")
Mockito.`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl") `when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl")
Mockito.`when`(imageInfo.getMetadata()?.licenseUrl()).thenReturn("licenseUrl") `when`(metadata.licenseUrl()).thenReturn("licenseUrl")
Mockito.`when`(imageInfo.getMetadata()?.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss") `when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss")
`when`(metadata.artist()).thenReturn("Foo Bar")
media = mediaConverter.convert(page, entity, imageInfo) media = mediaConverter.convert(page, entity, imageInfo)
assertEquals(media.thumbUrl, "thumbUrl") assertEquals(media.thumbUrl, "thumbUrl")
} }
@Test
fun `test converting artist value (author) with html links`() {
`when`(imageInfo.getMetadata()).thenReturn(metadata)
`when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl")
`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl")
`when`(metadata.licenseUrl()).thenReturn("licenseUrl")
`when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss")
`when`(metadata.artist()).thenReturn("<a href=\"//commons.wikimedia.org/wiki/User:Foo_Bar\" title=\"Foo Bar\">Foo Bar</a>")
// Artist values like above is very common, found in file pages created via UploadWizard
media = mediaConverter.convert(page, entity, imageInfo)
assertEquals("Foo Bar", media.author)
}
@Test
fun `test convert artist value (author) in plain text`() {
`when`(imageInfo.getMetadata()).thenReturn(metadata)
`when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl")
`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl")
`when`(metadata.licenseUrl()).thenReturn("licenseUrl")
`when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss")
`when`(metadata.artist()).thenReturn("Foo Bar")
media = mediaConverter.convert(page, entity, imageInfo)
assertEquals("Foo Bar", media.author)
}
@Test
fun `test convert artist value (author) containing red link`() {
`when`(imageInfo.getMetadata()).thenReturn(metadata)
`when`(imageInfo.getThumbUrl()).thenReturn("thumbUrl")
`when`(imageInfo.getOriginalUrl()).thenReturn("originalUrl")
`when`(metadata.licenseUrl()).thenReturn("licenseUrl")
`when`(metadata.dateTime()).thenReturn("yyyy-MM-dd HH:mm:ss")
`when`(metadata.artist()).thenReturn("<a href=\"/w/index.php?title=User:Foo&action=edit&redlink=1\" class=\"new\" title=\"User:Foo (page does not exist)\">Foo</a>")
media = mediaConverter.convert(page, entity, imageInfo)
assertEquals("Foo", media.author)
}
} }

View file

@ -0,0 +1,78 @@
package fr.free.nrw.commons.utils
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.media.IdAndLabels
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [21], application = TestCommonsApplication::class, qualifiers="en-rUS")
class MediaAttributionUtilTest {
@Mock
private lateinit var appContext: Context
@Before
fun setup() {
appContext = ApplicationProvider.getApplicationContext()
}
@Test
fun getTagLineWithUploaderOnly() {
val media = mock(Media::class.java)
whenever(media.user).thenReturn("TestUploader")
whenever(media.author).thenReturn(null)
assertEquals("Uploaded by: TestUploader",
MediaAttributionUtil.getTagLine(media, appContext))
}
@Test
fun `get tag line from same author and uploader`() {
val media = mock(Media::class.java)
whenever(media.user).thenReturn("TestUser")
whenever(media.getAttributedAuthor()).thenReturn("TestUser")
assertEquals("Created and uploaded by: TestUser",
MediaAttributionUtil.getTagLine(media, appContext))
}
@Test
fun `get creator name from EN label`() {
assertEquals("FooBar",
MediaAttributionUtil.getCreatorName(listOf(IdAndLabels("Q1", mapOf("en" to "FooBar")))))
}
@Test
fun `get creator name from ES label`() {
assertEquals("FooBar",
MediaAttributionUtil.getCreatorName(listOf(IdAndLabels("Q2", mapOf("es" to "FooBar")))))
}
@Test
fun `get creator name from EN label and ignore ES label`() {
assertEquals("Bar",
MediaAttributionUtil.getCreatorName(listOf(
IdAndLabels("Q3", mapOf("en" to "Bar", "es" to "Foo")))))
}
@Test
fun `get creator name from two creators`() {
val name = MediaAttributionUtil.getCreatorName(listOf(
IdAndLabels("Q1", mapOf("en" to "Foo")),
IdAndLabels("Q1", mapOf("en" to "Bar"))
))
assertNotNull(name)
assertTrue(name!!.contains("Foo"))
assertTrue(name.contains("Bar"))
}
}