Merge remote-tracking branch 'origin/main'

This commit is contained in:
Akshay Komar 2025-01-16 19:46:46 +05:30
commit 6d176216b1
27 changed files with 614 additions and 318 deletions

View file

@ -1,5 +1,89 @@
# Wikimedia Commons for Android
## v5.1.2
### What's changed
* Fix the broken category search in the explore screen
## v5.1.1
### What's changed
* Use Android's new EXIF interface to mitigate security issues in old
EXIF interface.
* Make the icon that helps view the upload queue always visible as it ensures
that the queue accessible at all times.
## v5.1.0
### What's Changed
* Enhanced **upload queue management** in the Commons app for smoother, sequential
processing, clearer progress tracking, prevention of stuck or duplicate
uploads. As part of this improvement, the "Limited Connection mode" has been
removed.
* Added an option in "Nearby" feature enabling users to **provide feedback on
Wikidata items**. Users can report if an item doesnt exist, is at a different
location, or has other issues, with submissions tagged for easy tracking and
updates.
* Improved the "Nearby" feature by splitting the query into two parts for faster
loading and **better performance, especially in areas with dense amount of
places**. This update also resolves issues with pins overlapping place names.
* Upgraded AGP and **target/compile SDK to 34** and make necessary adjustments to
the app such as adding **"Partial Access" support**. Also includes some minor
refactoring, and replacement of deprecated circular progress bars.
* Fixed an **UI issue where the 'Subcategories' and 'Parent Categories' tabs
appeared blank** in the Category Details screen. Resolved by optimizing view
binding handling in the parent fragments.
* Fixed an issue where editing depictions removed all other structured data from
images. Now, **only depictions are updated, preserving other associated data**.
* Fixed **map centering** in the image upload flow to **use GPS EXIF tag location**
from pictures and ensured "Show in map app" accurately reflects this location.
* Fixed navigation **after uploading via Nearby by directing users to the Uploads
activity** instead of returning to Nearby, preventing confusion about needing to
upload again.
### Bug fixes and various changes
* Improved the "Nearby" feature to fetch labels based on the user's preferred
language instead of defaulting to English.
* Added a legend to the "Nearby" feature indicating pin statuses: red for items
without pictures, green for those with pictures, and grey for items being
checked. A floating action button now allows users to toggle the legend's
visibility.
* Fixed an issue where the "Nominate for deletion" option is shown to logged out
users, preventing app errors and crashes.
* Updated the regex pattern that filters categories with an year in it to also
filter the 2020s.
* Fix an issue where past depictions were not shown as suggestions, despite
being saved correctly.
* Fixed an issue in custom image picker where exiting the media preview showed
only the first image and cleared selections. Now, previously selected images
are restored correctly after exiting the preview. This was contributed.
* Fixed an issue in custom image picker where scrolling behavior did not
maintain position after exiting fullscreen preview, ensuring users remain at
the same point in their image roll unless actioned images are filtered. This
was contributed.
* Fixed Nearby map not showing new pins on map move by removing the 2000m scroll
threshold and adding an 800ms debounce for smoother pin updates when the map
is moved. Queued searches are now canceled on fragment destruction.
* Revised author information retrieval to emphasize the custom author name from
the metadata instead of the default registered username.
* Enhanced notification classification to properly identify "email" type
notifications and prompting users to check their e-mail inbox when such
notifications are clicked.
* Resolved a bug in the language chooser that incorrectly greyed-out previously
selected languages, ensuring only the current language is non-selectable during
image upload.
* Resolved pin color update issue in "Nearby" feature where the pin colour
failed to be updated after a successful image upload.
What's listed here is only a subset of all the changes. Check the full-list of
the changes in [this link](https://github.com/commons-app/apps-android-commons/compare/v5.0.2...v5.1.0).
Alternatively, checkout [this release on GitHub releases page](https://github.com/commons-app/apps-android-commons/releases/tag/v5.1.0)
for an exhaustive list of changes and the various contributors who contributed the same.
## v5.0.2
- Enhanced multi-upload functionality with user prompts to clarify that all images would share the

View file

@ -212,8 +212,8 @@ android {
defaultConfig {
//applicationId 'fr.free.nrw.commons'
versionCode 1040
versionName '5.0.2'
versionCode 1043
versionName '5.1.2'
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
minSdkVersion 21

View file

@ -262,4 +262,4 @@
android:required="false" />
</application>
</manifest>
</manifest>

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
@ -28,13 +29,20 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
import javax.inject.Inject
/**
* SingleWebViewActivity is a reusable activity webView based on a given url(initial url) and
* closes itself when a specified success URL is reached to success url.
*/
class SingleWebViewActivity : ComponentActivity() {
@Inject
lateinit var cookieJar: CommonsCookieJar
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -44,6 +52,11 @@ class SingleWebViewActivity : ComponentActivity() {
finish()
return
}
ApplicationlessInjection
.getInstance(applicationContext)
.commonsApplicationComponent
.inject(this)
setCookies(url)
enableEdgeToEdge()
setContent {
Scaffold(
@ -131,6 +144,7 @@ class SingleWebViewActivity : ComponentActivity() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
setCookies(url.orEmpty())
}
}
@ -152,6 +166,20 @@ class SingleWebViewActivity : ComponentActivity() {
}
/**
* Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`.
*
* @param url The URL for which cookies need to be set.
*/
private fun setCookies(url: String) {
CookieManager.getInstance().let {
val cookies = cookieJar.loadForRequest(url.toHttpUrl())
for (cookie in cookies) {
it.setCookie(url, cookie.toString())
}
}
}
companion object {
private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"

View file

@ -127,30 +127,64 @@ class CategoriesModel
/**
* Fetches details of every category associated with selected depictions, converts them into
* CategoryItem and returns them in a list.
* If a selected depiction has no categories, the categories in which its P18 belongs are
* returned in the list.
*
* @param selectedDepictions selected DepictItems
* @return List of CategoryItem associated with selected depictions
*/
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? =
Observable
.fromIterable(
selectedDepictions.map { it.commonsCategories }.flatten(),
).map { categoryItem ->
categoryClient
.getCategoriesByName(
categoryItem.name,
categoryItem.name,
SEARCH_CATS_LIMIT,
).map {
CategoryItem(
it[0].name,
it[0].description,
it[0].thumbnail,
it[0].isSelected,
)
}.blockingGet()
}.toList()
.toObservable()
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? {
val observables = selectedDepictions.map { depictedItem ->
if (depictedItem.commonsCategories.isEmpty()) {
if (depictedItem.primaryImage == null) {
return@map Observable.just(emptyList<CategoryItem>())
}
Observable.just(
depictedItem.primaryImage
).map { image ->
categoryClient
.getCategoriesOfImage(
image,
SEARCH_CATS_LIMIT,
).map {
it.map { category ->
CategoryItem(
category.name,
category.description,
category.thumbnail,
category.isSelected,
)
}
}.blockingGet()
}.flatMapIterable { it }.toList()
.toObservable()
} else {
Observable
.fromIterable(
depictedItem.commonsCategories,
).map { categoryItem ->
categoryClient
.getCategoriesByName(
categoryItem.name,
categoryItem.name,
SEARCH_CATS_LIMIT,
).map {
CategoryItem(
it[0].name,
it[0].description,
it[0].thumbnail,
it[0].isSelected,
)
}.blockingGet()
}.toList()
.toObservable()
}
}
return Observable.concat(observables)
.scan(mutableListOf<CategoryItem>()) { accumulator, currentList ->
accumulator.apply { addAll(currentList) }
}
}
/**
* Fetches details of every category by their name, converts them into

View file

@ -78,6 +78,24 @@ class CategoryClient
),
)
/**
* Fetches categories belonging to an image (P18 of some wikidata entity).
*
* @param image P18 of some wikidata entity
* @param itemLimit How many categories to return
* @return Single Observable emitting the list of categories
*/
fun getCategoriesOfImage(
image: String,
itemLimit: Int,
): Single<List<CategoryItem>> =
responseMapper(
categoryInterface.getCategoriesByTitles(
"File:${image}",
itemLimit,
),
)
/**
* The method takes categoryName as input and returns a List of Subcategories
* It uses the generator query API to get the subcategories in a category, 500 at a time.

View file

@ -61,6 +61,21 @@ interface CategoryInterface {
@Query("gacoffset") offset: Int,
): Single<MwQueryResponse>
/**
* Fetches non-hidden categories by titles.
*
* @param titles titles to fetch categories for (e.g. File:<P18 of a wikidata entity>)
* @param itemLimit How many categories to return
* @return MwQueryResponse
*/
@GET(
"w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70&gclshow=!hidden",
)
fun getCategoriesByTitles(
@Query("titles") titles: String?,
@Query("gcllimit") itemLimit: Int,
): Single<MwQueryResponse>
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
fun getSubCategoryList(
@Query("gcmtitle") categoryName: String,

View file

@ -101,7 +101,7 @@ data class Contribution constructor(
*/
fun formatCaptions(uploadMediaDetails: List<UploadMediaDetail>) =
uploadMediaDetails
.associate { it.languageCode!! to it.captionText!! }
.associate { it.languageCode!! to it.captionText }
.filter { it.value.isNotBlank() }
/**

View file

@ -267,11 +267,11 @@ class DescriptionEditActivity :
applicationContext,
media,
mediaDetail.languageCode!!,
mediaDetail.captionText!!,
mediaDetail.captionText,
).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { s: Boolean? ->
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!!
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
media.captions = updatedCaptions
Timber.d("Caption is added.")
},

View file

@ -6,6 +6,7 @@ import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector
import dagger.android.support.AndroidSupportInjectionModule
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.activity.SingleWebViewActivity
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.contributions.ContributionsModule
import fr.free.nrw.commons.explore.SearchModule
@ -51,6 +52,8 @@ interface CommonsApplicationComponent : AndroidInjector<ApplicationlessInjection
fun inject(activity: LoginActivity)
fun inject(activity: SingleWebViewActivity)
fun inject(fragment: SettingsFragment)
fun inject(fragment: MoreBottomSheetFragment)

View file

@ -467,18 +467,35 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
}
}
/**
* Retrieves the ContributionsFragment that is potentially the parent, grandparent, etc
* fragment of this fragment.
*
* @return The ContributionsFragment instance. If the ContributionsFragment instance could not
* be found, null is returned.
*/
private fun getContributionsFragmentParent(): ContributionsFragment? {
var fragment: Fragment? = this
while (fragment != null && fragment !is ContributionsFragment) {
fragment = fragment.parentFragment
}
if (fragment == null) {
return null
}
return fragment as ContributionsFragment
}
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
}
val contributionsFragment: ContributionsFragment? = this.getContributionsFragmentParent()
if (contributionsFragment?.binding != null) {
contributionsFragment.binding.cardViewNearby.visibility = View.GONE
}
// detail provider is null when fragment is shown in review activity
media = if (detailProvider != null) {
detailProvider!!.getMediaAtPosition(index)
@ -1569,7 +1586,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
mediaDetail: UploadMediaDetail,
updatedCaptions: MutableMap<String, String>
) {
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!!
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
media!!.captions = updatedCaptions
}
@ -1737,10 +1754,11 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
return
}
ProfileActivity.startYourself(
activity,
media!!.user,
sessionManager.userName != media!!.user
requireActivity(), // Ensure this is a non-null Activity context
media?.user ?: "", // Provide a fallback value if media?.user is null
sessionManager.userName != media?.user // This can remain as is, null check will apply
)
}
/**

View file

@ -1,265 +0,0 @@
package fr.free.nrw.commons.profile;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionsFragment;
import fr.free.nrw.commons.databinding.ActivityProfileBinding;
import fr.free.nrw.commons.profile.achievements.AchievementsFragment;
import fr.free.nrw.commons.profile.leaderboard.LeaderboardFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
/**
* This activity will set two tabs, achievements and
* each tab will have their own fragments
*/
public class ProfileActivity extends BaseActivity {
private FragmentManager supportFragmentManager;
public ActivityProfileBinding binding;
@Inject
SessionManager sessionManager;
private ViewPagerAdapter viewPagerAdapter;
private AchievementsFragment achievementsFragment;
private LeaderboardFragment leaderboardFragment;
public static final String KEY_USERNAME ="username";
public static final String KEY_SHOULD_SHOW_CONTRIBUTIONS ="shouldShowContributions";
String userName;
private boolean shouldShowContributions;
ContributionsFragment contributionsFragment;
public void setScroll(boolean canScroll){
binding.viewPager.setCanScroll(canScroll);
}
@Override
protected void onRestoreInstanceState(final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
if (savedInstanceState != null) {
userName = savedInstanceState.getString(KEY_USERNAME);
shouldShowContributions = savedInstanceState.getBoolean(KEY_SHOULD_SHOW_CONTRIBUTIONS);
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityProfileBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbarBinding.toolbar);
binding.toolbarBinding.toolbar.setNavigationOnClickListener(view -> {
onSupportNavigateUp();
});
userName = getIntent().getStringExtra(KEY_USERNAME);
setTitle(userName);
shouldShowContributions = getIntent().getBooleanExtra(KEY_SHOULD_SHOW_CONTRIBUTIONS, false);
supportFragmentManager = getSupportFragmentManager();
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
binding.viewPager.setAdapter(viewPagerAdapter);
binding.tabLayout.setupWithViewPager(binding.viewPager);
setTabs();
}
/**
* Navigate up event
* @return boolean
*/
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
/**
* Creates a way to change current activity to AchievementActivity
*
* @param context
*/
public static void startYourself(final Context context, final String userName,
final boolean shouldShowContributions) {
Intent intent = new Intent(context, ProfileActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_SINGLE_TOP);
intent.putExtra(KEY_USERNAME, userName);
intent.putExtra(KEY_SHOULD_SHOW_CONTRIBUTIONS, shouldShowContributions);
context.startActivity(intent);
}
/**
* Set the tabs for the fragments
*/
private void setTabs() {
List<Fragment> fragmentList = new ArrayList<>();
List<String> titleList = new ArrayList<>();
achievementsFragment = new AchievementsFragment();
Bundle achievementsBundle = new Bundle();
achievementsBundle.putString(KEY_USERNAME, userName);
achievementsFragment.setArguments(achievementsBundle);
fragmentList.add(achievementsFragment);
titleList.add(getResources().getString(R.string.achievements_tab_title).toUpperCase());
leaderboardFragment = new LeaderboardFragment();
Bundle leaderBoardBundle = new Bundle();
leaderBoardBundle.putString(KEY_USERNAME, userName);
leaderboardFragment.setArguments(leaderBoardBundle);
fragmentList.add(leaderboardFragment);
titleList.add(getResources().getString(R.string.leaderboard_tab_title).toUpperCase(Locale.ROOT));
contributionsFragment = new ContributionsFragment();
Bundle contributionsListBundle = new Bundle();
contributionsListBundle.putString(KEY_USERNAME, userName);
contributionsFragment.setArguments(contributionsListBundle);
fragmentList.add(contributionsFragment);
titleList.add(getString(R.string.contributions_fragment).toUpperCase(Locale.ROOT));
viewPagerAdapter.setTabData(fragmentList, titleList);
viewPagerAdapter.notifyDataSetChanged();
}
@Override
public void onDestroy() {
super.onDestroy();
getCompositeDisposable().clear();
}
/**
* To inflate menu
* @param menu Menu
* @return boolean
*/
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
final MenuInflater menuInflater = getMenuInflater();
menuInflater.inflate(R.menu.menu_about, menu);
return super.onCreateOptionsMenu(menu);
}
/**
* To receive the id of selected item and handle further logic for that selected item
* @param item MenuItem
* @return boolean
*/
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
// take screenshot in form of bitmap and show it in Alert Dialog
if (item.getItemId() == R.id.share_app_icon) {
final View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
final Bitmap screenShot = Utils.getScreenShot(rootView);
showAlert(screenShot);
return true;
}
return super.onOptionsItemSelected(item);
}
/**
* It displays the alertDialog with Image of screenshot
* @param screenshot screenshot of the present screen
*/
public void showAlert(final Bitmap screenshot) {
final LayoutInflater factory = LayoutInflater.from(this);
final View view = factory.inflate(R.layout.image_alert_layout, null);
final ImageView screenShotImage = view.findViewById(R.id.alert_image);
screenShotImage.setImageBitmap(screenshot);
final TextView shareMessage = view.findViewById(R.id.alert_text);
shareMessage.setText(R.string.achievements_share_message);
DialogUtil.showAlertDialog(this,
null,
null,
getString(R.string.about_translate_proceed),
getString(R.string.cancel),
() -> shareScreen(screenshot),
() -> {},
view
);
}
/**
* To take bitmap and store it temporary storage and share it
* @param bitmap bitmap of screenshot
*/
void shareScreen(final Bitmap bitmap) {
try {
final File file = new File(getExternalCacheDir(), "screen.png");
final FileOutputStream fileOutputStream = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream);
fileOutputStream.flush();
fileOutputStream.close();
file.setReadable(true, false);
final Uri fileUri = FileProvider
.getUriForFile(getApplicationContext(),
getPackageName() + ".provider", file);
grantUriPermission(getPackageName(), fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
final Intent intent = new Intent(android.content.Intent.ACTION_SEND);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(Intent.EXTRA_STREAM, fileUri);
intent.setType("image/png");
startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)));
} catch (final IOException e) {
e.printStackTrace();
}
}
@Override
protected void onSaveInstanceState(@NonNull final Bundle outState) {
outState.putString(KEY_USERNAME, userName);
outState.putBoolean(KEY_SHOULD_SHOW_CONTRIBUTIONS, shouldShowContributions);
super.onSaveInstanceState(outState);
}
@Override
public void onBackPressed() {
// Checking if MediaDetailPagerFragment is visible, If visible then show ContributionListFragment else close the ProfileActivity
if(contributionsFragment != null && contributionsFragment.getMediaDetailPagerFragment() != null && contributionsFragment.getMediaDetailPagerFragment().isVisible()) {
contributionsFragment.backButtonClicked();
binding.tabLayout.setVisibility(View.VISIBLE);
}else {
super.onBackPressed();
}
}
/**
* To set the visibility of tab layout
* @param isVisible boolean
*/
public void setTabLayoutVisibility(boolean isVisible) {
binding.tabLayout.setVisibility(isVisible ? View.VISIBLE : View.GONE);
}
}

View file

@ -0,0 +1,229 @@
package fr.free.nrw.commons.profile
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.ViewPagerAdapter
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.ContributionsFragment
import fr.free.nrw.commons.databinding.ActivityProfileBinding
import fr.free.nrw.commons.profile.achievements.AchievementsFragment
import fr.free.nrw.commons.profile.leaderboard.LeaderboardFragment
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.DialogUtil
import java.io.File
import java.io.FileOutputStream
import java.util.*
import javax.inject.Inject
/**
* This activity will set two tabs, achievements and
* each tab will have their own fragments
*/
class ProfileActivity : BaseActivity() {
lateinit var binding: ActivityProfileBinding
@Inject
lateinit var sessionManager: SessionManager
private lateinit var viewPagerAdapter: ViewPagerAdapter
private lateinit var achievementsFragment: AchievementsFragment
private lateinit var leaderboardFragment: LeaderboardFragment
private lateinit var userName: String
private var shouldShowContributions: Boolean = false
private var contributionsFragment: ContributionsFragment? = null
fun setScroll(canScroll: Boolean) {
binding.viewPager.setCanScroll(canScroll)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
savedInstanceState.let {
userName = it.getString(KEY_USERNAME, "")
shouldShowContributions = it.getBoolean(KEY_SHOULD_SHOW_CONTRIBUTIONS)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityProfileBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbarBinding.toolbar)
binding.toolbarBinding.toolbar.setNavigationOnClickListener {
onSupportNavigateUp()
}
userName = intent.getStringExtra(KEY_USERNAME) ?: ""
title = userName
shouldShowContributions = intent.getBooleanExtra(KEY_SHOULD_SHOW_CONTRIBUTIONS, false)
viewPagerAdapter = ViewPagerAdapter(supportFragmentManager)
binding.viewPager.adapter = viewPagerAdapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
setTabs()
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
private fun setTabs() {
val fragmentList = mutableListOf<Fragment>()
val titleList = mutableListOf<String>()
// Add Achievements tab
achievementsFragment = AchievementsFragment().apply {
arguments = Bundle().apply {
putString(KEY_USERNAME, userName)
}
}
fragmentList.add(achievementsFragment)
titleList.add(resources.getString(R.string.achievements_tab_title).uppercase())
// Add Leaderboard tab
leaderboardFragment = LeaderboardFragment().apply {
arguments = Bundle().apply {
putString(KEY_USERNAME, userName)
}
}
fragmentList.add(leaderboardFragment)
titleList.add(resources.getString(R.string.leaderboard_tab_title).uppercase(Locale.ROOT))
// Add Contributions tab
contributionsFragment = ContributionsFragment().apply {
arguments = Bundle().apply {
putString(KEY_USERNAME, userName)
}
}
contributionsFragment?.let {
fragmentList.add(it)
titleList.add(getString(R.string.contributions_fragment).uppercase(Locale.ROOT))
}
viewPagerAdapter.setTabData(fragmentList, titleList)
viewPagerAdapter.notifyDataSetChanged()
}
public override fun onDestroy() {
super.onDestroy()
compositeDisposable.clear()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_about, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.share_app_icon -> {
val rootView = window.decorView.findViewById<View>(android.R.id.content)
val screenShot = Utils.getScreenShot(rootView)
if (screenShot == null) {
Log.e("ERROR", "ScreenShot is null")
return false
}
showAlert(screenShot)
true
}
else -> super.onOptionsItemSelected(item)
}
}
fun showAlert(screenshot: Bitmap) {
val view = layoutInflater.inflate(R.layout.image_alert_layout, null)
val screenShotImage = view.findViewById<ImageView>(R.id.alert_image)
val shareMessage = view.findViewById<TextView>(R.id.alert_text)
screenShotImage.setImageBitmap(screenshot)
shareMessage.setText(R.string.achievements_share_message)
DialogUtil.showAlertDialog(
this,
null,
null,
getString(R.string.about_translate_proceed),
getString(R.string.cancel),
{ shareScreen(screenshot) },
{},
view
)
}
private fun shareScreen(bitmap: Bitmap) {
try {
val file = File(externalCacheDir, "screen.png")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
out.flush()
}
file.setReadable(true, false)
val fileUri = FileProvider.getUriForFile(
applicationContext,
"$packageName.provider",
file
)
grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(Intent.EXTRA_STREAM, fileUri)
type = "image/png"
}
startActivity(Intent.createChooser(intent, getString(R.string.share_image_via)))
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(KEY_USERNAME, userName)
outState.putBoolean(KEY_SHOULD_SHOW_CONTRIBUTIONS, shouldShowContributions)
}
override fun onBackPressed() {
if (contributionsFragment?.mediaDetailPagerFragment?.isVisible == true) {
contributionsFragment?.backButtonClicked()
binding.tabLayout.visibility = View.VISIBLE
} else {
super.onBackPressed()
}
}
fun setTabLayoutVisibility(isVisible: Boolean) {
binding.tabLayout.visibility = if (isVisible) View.VISIBLE else View.GONE
}
companion object {
const val KEY_USERNAME = "username"
const val KEY_SHOULD_SHOW_CONTRIBUTIONS = "shouldShowContributions"
@JvmStatic
fun startYourself(context: Context, userName: String, shouldShowContributions: Boolean) {
val intent = Intent(context, ProfileActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(KEY_USERNAME, userName)
putExtra(KEY_SHOULD_SHOW_CONTRIBUTIONS, shouldShowContributions)
}
context.startActivity(intent)
}
}
}

View file

@ -58,7 +58,7 @@ class LeaderboardListAdapter : PagedListAdapter<LeaderboardList, ListViewHolder>
if (view.context is ProfileActivity) {
((view.context) as Activity).finish()
}
ProfileActivity.startYourself(view.context, item.username, true)
ProfileActivity.startYourself(view.context, item.username?:"", true)
}
}
}

View file

@ -23,14 +23,14 @@ data class UploadMediaDetail(
* The caption text for the item being uploaded.
* @param captionText The caption text.
*/
var captionText: String? = "",
var captionText: String = "",
) : Parcelable {
fun javaCopy() = copy()
constructor(place: Place?) : this(
place?.language,
place?.longDescription,
place?.name,
place?.name ?: "",
)
/**

View file

@ -140,7 +140,7 @@ class CategoriesPresenter
*/
private fun getImageTitleList(): List<String> =
repository.getUploads()
.map { it.uploadMediaDetails[0].captionText!! }
.map { it.uploadMediaDetails[0].captionText }
.filterNot { TextUtils.isEmpty(it) }
/**

View file

@ -68,6 +68,9 @@ data class DepictedItem constructor(
entity.id(),
)
val primaryImage: String?
get() = imageUrl?.split('-')?.lastOrNull()
override fun equals(other: Any?) =
when {
this === other -> true

View file

@ -8,7 +8,7 @@
android:fillViewport="true"
tools:ignore="ContentDescription" >
<!-- TODO Add ContentDescription For ALL Images Added ignore to suppress Lints -->
<!-- TODO Add ContentDescription For ALL Images Added ignore to suppress Lints -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout"

View file

@ -875,6 +875,11 @@
<string name="usages_on_commons_heading">كومنز</string>
<string name="usages_on_other_wikis_heading">مواقع ويكي أخرى</string>
<string name="file_usages_container_heading">حالات استخدام الملف</string>
<string name="title_activity_single_web_view">نشاط عرض ويب واحد</string>
<string name="account">حساب</string>
<string name="vanish_account">حذف الحساب</string>
<string name="account_vanish_request_confirm_title">تحذير من اختفاء الحساب</string>
<string name="account_vanish_request_confirm">الاختفاء هو &lt;b&gt;الملاذ الأخير&lt;/b&gt; ويجب &lt;b&gt;استخدامه فقط عندما ترغب في التوقف عن التحرير إلى الأبد&lt;/b&gt; وأيضًا لإخفاء أكبر عدد ممكن من ارتباطاتك السابقة.&lt;br/&gt;&lt;br/&gt; يتم حذف الحساب على ويكيميديا كومنز عن طريق تغيير اسم حسابك بحيث لا يتمكن الآخرون من التعرف على مساهماتك في عملية تسمى اختفاء الحساب. &lt;b&gt;لا يضمن الاختفاء عدم الكشف عن الهوية تمامًا أو إزالة المساهمات في المشاريع&lt;/b&gt; .</string>
<string name="caption">الشرح</string>
<string name="caption_copied_to_clipboard">تم نسخ التسمية التوضيحية إلى الحافظة</string>
</resources>

View file

@ -708,6 +708,7 @@
<string name="usages_on_other_wikis_heading">다른 위키</string>
<string name="file_usages_container_heading">이 파일을 사용하는 문서</string>
<string name="account">계정</string>
<string name="vanish_account">계정 버리기</string>
<string name="caption">캡션</string>
<string name="caption_copied_to_clipboard">캡션이 클립보드에 복사되었습니다</string>
</resources>

View file

@ -109,7 +109,7 @@
<string name="welcome_copyright_text">Сиздин сүрөттөр дүйнө жүзүндөгү адамдардын билим алышына өбөлгө түзүүдө.</string>
<string name="welcome_copyright_subtext">Интернетте жарыяланган автордук укукка ээ сүрөттөрдөн, ошондой эле плакаттардан жана китептердин мукабасынан ж.б. четтеңиз.</string>
<string name="welcome_final_text">Сизге бул түшүнүктүүбү?</string>
<string name="welcome_final_button_text">Ооба !</string>
<string name="welcome_final_button_text">Ооба!</string>
<string name="detail_panel_cats_label">Категориялар</string>
<string name="detail_panel_cats_loading">Жүктөлүүдө…</string>
<string name="detail_panel_cats_none">Тандалган жок</string>

View file

@ -831,4 +831,8 @@
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Andere wikis</string>
<string name="file_usages_container_heading">Bestandsgebruik</string>
<string name="title_activity_single_web_view">Activiteit enkele webraadpleging</string>
<string name="account">Account</string>
<string name="vanish_account">Account laten verdwijnen</string>
<string name="account_vanish_request_confirm_title">Waarschuwing verwijdering account</string>
</resources>

View file

@ -813,7 +813,11 @@
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Andra wikier</string>
<string name="file_usages_container_heading">Filanvändning</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</string>
<string name="account">Konto</string>
<string name="vanish_account">Få kontot att försvinna</string>
<string name="account_vanish_request_confirm_title">Varning om försvinnande konto</string>
<string name="account_vanish_request_confirm">Att få kontot att försvinna är en &lt;b&gt;sista utväg&lt;/b&gt; och bör &lt;b&gt;endast användas när du vill sluta redigera för alltid&lt;/b&gt; och även dölja så många av dina tidigare associationer som möjligt.&lt;br/&gt;&lt;br/&gt;Konton raderas på Wikimedia Commons genom att ändra kontonamnet för att göra så att andra inte kan känna igen bidragen i en process som kallas kontoförsvinnande. &lt;b&gt;Försvinnande garanterar inte fullständig anonymitet eller att bidrag tas bort från projekten&lt;/b&gt;.</string>
<string name="caption">Bildtext</string>
<string name="caption_copied_to_clipboard">Bildtext kopierades till urklipp</string>
</resources>

View file

@ -20,6 +20,7 @@
<item name="reviewHeading">@color/white</item>
<item name="aboutIconsColor">@color/white</item>
<item name="caption_description_text_color">@color/white</item>
<item name="android:statusBarColor">@color/main_background_dark</item>
<item name="semitransparentText">@color/commons_app_blue_dark</item>
<item name="subBackground">@color/sub_background_dark</item>

View file

@ -1,7 +1,9 @@
package fr.free.nrw.commons.category
import categoryItem
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import depictedItem
@ -90,14 +92,18 @@ class CategoriesModelTest {
val depictedItem =
depictedItem(
commonsCategories =
listOf(
CategoryItem(
"depictionCategory",
"",
"",
false,
),
listOf(
CategoryItem(
"depictionCategory",
"",
"",
false,
),
),
)
val depictedItemWithoutCategories =
depictedItem(
imageUrl = "testUrl"
)
whenever(gpsCategoryModel.categoriesFromLocation)
@ -159,6 +165,23 @@ class CategoriesModelTest {
),
),
)
whenever(
categoryClient.getCategoriesOfImage(
"testUrl",
25,
),
).thenReturn(
Single.just(
listOf(
CategoryItem(
"categoriesOfP18",
"",
"",
false,
),
),
),
)
val imageTitleList = listOf("Test")
CategoriesModel(categoryClient, categoryDao, gpsCategoryModel)
.searchAll("", imageTitleList, listOf(depictedItem))
@ -171,8 +194,21 @@ class CategoriesModelTest {
categoryItem("recentCategories"),
),
)
CategoriesModel(categoryClient, categoryDao, gpsCategoryModel)
.searchAll("", imageTitleList, listOf(depictedItemWithoutCategories))
.test()
.assertValue(
listOf(
categoryItem("categoriesOfP18"),
categoryItem("gpsCategory"),
categoryItem("titleSearch"),
categoryItem("recentCategories"),
),
)
imageTitleList.forEach {
verify(categoryClient).searchCategories(it, CategoriesModel.SEARCH_CATS_LIMIT)
verify(categoryClient, times(2)).searchCategories(it, CategoriesModel.SEARCH_CATS_LIMIT)
verify(categoryClient).getCategoriesByName(any(), any(), any(), any())
verify(categoryClient).getCategoriesOfImage(any(), any())
}
}

View file

@ -132,6 +132,45 @@ class CategoryClientTest {
)
}
@Test
fun getCategoriesByTitlesFound() {
val mockResponse = withMockResponse("Category:Test")
whenever(
categoryInterface.getCategoriesByTitles(
anyString(),
anyInt(),
),
).thenReturn(Single.just(mockResponse))
categoryClient
.getCategoriesOfImage("tes", 10)
.test()
.assertValues(
listOf(
CategoryItem(
"Test",
"",
"",
false,
),
),
)
categoryClient
.getCategoriesOfImage(
"tes",
10,
).test()
.assertValues(
listOf(
CategoryItem(
"Test",
"",
"",
false,
),
),
)
}
@Test
fun getCategoriesByNameNull() {
val mockResponse = withNullPages()
@ -160,6 +199,29 @@ class CategoryClientTest {
.assertValues(emptyList())
}
@Test
fun getCategoriesByTitlesNull() {
val mockResponse = withNullPages()
whenever(
categoryInterface.getCategoriesByTitles(
anyString(),
anyInt(),
),
).thenReturn(Single.just(mockResponse))
categoryClient
.getCategoriesOfImage(
"tes",
10,
).test()
.assertValues(emptyList())
categoryClient
.getCategoriesOfImage(
"tes",
10,
).test()
.assertValues(emptyList())
}
@Test
fun getParentCategoryListFound() {
val mockResponse = withMockResponse("Category:Test")

View file

@ -181,4 +181,20 @@ class DepictedItemTest {
fun `hashCode returns different values for objects with different name`() {
Assert.assertNotEquals(depictedItem(name = "a").hashCode(), depictedItem(name = "b").hashCode())
}
@Test
fun `primaryImage is derived correctly from imageUrl`() {
Assert.assertEquals(
DepictedItem(
entity(
statements = mapOf(
WikidataProperties.IMAGE.propertyName to listOf(
statement(snak(dataValue = valueString("prefix: example_image name"))),
),
),
),
).primaryImage,
"_example_image_name",
)
}
}