Convert upload to kotlin (part 2) (#6069)

* Convert UploadCategoriesFragment to kotlin

* Convert DepictsFragment to kotlin

* Convert MediaLicensePresenter to kotlin

* Convert MediaLicenseFragment to kotlin

* Converted SimilarImageDialogFragment to kotlin

* Convert ThumbnailsAdapter to kotlin

* Convert UploadPresenter to kotlin

* Convert UploadBaseFragment to kotlin

* Convert UploadMediaDetailInputFilter to kotlin

* Convert UploadItem to kotlin

* Convert UploadController to kotlin

* Fix nullability of the UploadItem
This commit is contained in:
Paul Hawke 2024-12-24 01:11:46 -06:00 committed by GitHub
parent 369e79be5e
commit a9058d129e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1830 additions and 2100 deletions

View file

@ -63,13 +63,13 @@ data class Contribution constructor(
Media(
formatCaptions(item.uploadMediaDetails),
categories,
item.fileName,
item.filename,
formatDescriptions(item.uploadMediaDetails),
sessionManager.userName,
sessionManager.userName,
),
localUri = item.mediaUri,
decimalCoords = item.gpsCoords.decimalCoords,
decimalCoords = item.gpsCoords?.decimalCoords,
dateCreatedSource = "",
depictedItems = depictedItems,
wikidataPlace = from(item.place),

View file

@ -13,7 +13,7 @@ class MimeTypeMapWrapper {
)
@JvmStatic
fun getExtensionFromMimeType(mimeType: String): String? {
fun getExtensionFromMimeType(mimeType: String?): String? {
val result = sMimeTypeToExtensionMap[mimeType]
if (result != null) {
return result

View file

@ -243,7 +243,7 @@ class UploadRepository @Inject constructor(
*
* @param licenseName
*/
fun setSelectedLicense(licenseName: String) {
fun setSelectedLicense(licenseName: String?) {
uploadModel.selectedLicense = licenseName
}

View file

@ -45,7 +45,7 @@ class ImageProcessingService @Inject constructor(
}
Timber.d("Checking the validity of image")
val filePath = uploadItem.mediaUri.path
val filePath = uploadItem.mediaUri?.path
return Single.zip(
checkDuplicateImage(filePath),
@ -107,7 +107,7 @@ class ImageProcessingService @Inject constructor(
return Single.just(EMPTY_CAPTION)
}
return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.fileName)
return mediaClient.checkPageExistsUsingTitle("File:" + uploadItem.filename)
.map { doesFileExist: Boolean ->
Timber.d("Result for valid title is %s", doesFileExist)
if (doesFileExist) FILE_NAME_EXISTS else IMAGE_OK

View file

@ -1,109 +0,0 @@
package fr.free.nrw.commons.upload;
import android.app.Dialog;
import android.content.DialogInterface;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.databinding.FragmentSimilarImageDialogBinding;
import java.io.File;
/**
* Created by harisanker on 14/2/18.
*/
public class SimilarImageDialogFragment extends DialogFragment {
Callback callback;//Implemented interface from shareActivity
Boolean gotResponse = false;
private FragmentSimilarImageDialogBinding binding;
public SimilarImageDialogFragment() {
}
public interface Callback {
void onPositiveResponse();
void onNegativeResponse();
}
public void setCallback(Callback callback) {
this.callback = callback;
}
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentSimilarImageDialogBinding.inflate(inflater, container, false);
binding.orginalImage.setHierarchy(GenericDraweeHierarchyBuilder
.newInstance(getResources())
.setPlaceholderImage(VectorDrawableCompat.create(getResources(),
R.drawable.ic_image_black_24dp,getContext().getTheme()))
.setFailureImage(VectorDrawableCompat.create(getResources(),
R.drawable.ic_error_outline_black_24dp, getContext().getTheme()))
.build());
binding.possibleImage.setHierarchy(GenericDraweeHierarchyBuilder
.newInstance(getResources())
.setPlaceholderImage(VectorDrawableCompat.create(getResources(),
R.drawable.ic_image_black_24dp,getContext().getTheme()))
.setFailureImage(VectorDrawableCompat.create(getResources(),
R.drawable.ic_error_outline_black_24dp, getContext().getTheme()))
.build());
binding.orginalImage.setImageURI(Uri.fromFile(new File(getArguments().getString("originalImagePath"))));
binding.possibleImage.setImageURI(Uri.fromFile(new File(getArguments().getString("possibleImagePath"))));
binding.postiveButton.setOnClickListener(v -> onPositiveButtonClicked());
binding.negativeButton.setOnClickListener(v -> onNegativeButtonClicked());
return binding.getRoot();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Dialog dialog = super.onCreateDialog(savedInstanceState);
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
return dialog;
}
@Override
public void onDismiss(DialogInterface dialog) {
// I user dismisses dialog by pressing outside the dialog.
if (!gotResponse) {
callback.onNegativeResponse();
}
super.onDismiss(dialog);
}
public void onNegativeButtonClicked() {
callback.onNegativeResponse();
gotResponse = true;
dismiss();
}
public void onPositiveButtonClicked() {
callback.onPositiveResponse();
gotResponse = true;
dismiss();
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,105 @@
package fr.free.nrw.commons.upload
import android.app.Dialog
import android.content.DialogInterface
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import androidx.fragment.app.DialogFragment
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.FragmentSimilarImageDialogBinding
import java.io.File
/**
* Created by harisanker on 14/2/18.
*/
class SimilarImageDialogFragment : DialogFragment() {
var callback: Callback? = null //Implemented interface from shareActivity
var gotResponse: Boolean = false
private var _binding: FragmentSimilarImageDialogBinding? = null
private val binding: FragmentSimilarImageDialogBinding get() = _binding!!
interface Callback {
fun onPositiveResponse()
fun onNegativeResponse()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSimilarImageDialogBinding.inflate(inflater, container, false)
binding.orginalImage.hierarchy =
GenericDraweeHierarchyBuilder.newInstance(resources).setPlaceholderImage(
VectorDrawableCompat.create(
resources, R.drawable.ic_image_black_24dp, requireContext().theme
)
).setFailureImage(
VectorDrawableCompat.create(
resources, R.drawable.ic_error_outline_black_24dp, requireContext().theme
)
).build()
binding.possibleImage.hierarchy =
GenericDraweeHierarchyBuilder.newInstance(resources).setPlaceholderImage(
VectorDrawableCompat.create(
resources, R.drawable.ic_image_black_24dp, requireContext().theme
)
).setFailureImage(
VectorDrawableCompat.create(
resources, R.drawable.ic_error_outline_black_24dp, requireContext().theme
)
).build()
arguments?.let {
binding.orginalImage.setImageURI(
Uri.fromFile(File(it.getString("originalImagePath")!!))
)
binding.possibleImage.setImageURI(
Uri.fromFile(File(it.getString("possibleImagePath")!!))
)
}
binding.postiveButton.setOnClickListener {
callback?.onPositiveResponse()
gotResponse = true
dismiss()
}
binding.negativeButton.setOnClickListener {
callback?.onNegativeResponse()
gotResponse = true
dismiss()
}
return binding.root
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
return dialog
}
override fun onDismiss(dialog: DialogInterface) {
// I user dismisses dialog by pressing outside the dialog.
if (!gotResponse) {
callback?.onNegativeResponse()
}
super.onDismiss(dialog)
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}

View file

@ -1,141 +0,0 @@
package fr.free.nrw.commons.upload;
import android.content.Context;
import android.graphics.drawable.GradientDrawable;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.facebook.drawee.view.SimpleDraweeView;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.databinding.ItemUploadThumbnailBinding;
import fr.free.nrw.commons.filepicker.UploadableFile;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* The adapter class for image thumbnails to be shown while uploading.
*/
class ThumbnailsAdapter extends RecyclerView.Adapter<ThumbnailsAdapter.ViewHolder> {
public static Context context;
List<UploadableFile> uploadableFiles;
private Callback callback;
private OnThumbnailDeletedListener listener;
private ItemUploadThumbnailBinding binding;
public ThumbnailsAdapter(Callback callback) {
this.uploadableFiles = new ArrayList<>();
this.callback = callback;
}
/**
* Sets the data, the media files
* @param uploadableFiles
*/
public void setUploadableFiles(
List<UploadableFile> uploadableFiles) {
this.uploadableFiles=uploadableFiles;
notifyDataSetChanged();
}
public void setOnThumbnailDeletedListener(OnThumbnailDeletedListener listener) {
this.listener = listener;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
binding = ItemUploadThumbnailBinding.inflate(LayoutInflater.from(viewGroup.getContext()), viewGroup, false);
return new ViewHolder(binding.getRoot());
}
@Override
public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
viewHolder.bind(position);
}
@Override
public int getItemCount() {
return uploadableFiles.size();
}
public class ViewHolder extends RecyclerView.ViewHolder {
RelativeLayout rlContainer;
SimpleDraweeView background;
ImageView ivError;
ImageView ivCross;
public ViewHolder(@NonNull View itemView) {
super(itemView);
rlContainer = binding.rlContainer;
background = binding.ivThumbnail;
ivError = binding.ivError;
ivCross = binding.icCross;
}
/**
* Binds a row item to the ViewHolder
* @param position
*/
public void bind(int position) {
UploadableFile uploadableFile = uploadableFiles.get(position);
Uri uri = uploadableFile.getMediaUri();
background.setImageURI(Uri.fromFile(new File(String.valueOf(uri))));
if (position == callback.getCurrentSelectedFilePosition()) {
GradientDrawable border = new GradientDrawable();
border.setShape(GradientDrawable.RECTANGLE);
border.setStroke(8, context.getResources().getColor(R.color.primaryColor));
rlContainer.setEnabled(true);
rlContainer.setClickable(true);
rlContainer.setAlpha(1.0f);
rlContainer.setBackground(border);
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
rlContainer.setElevation(10);
}
} else {
rlContainer.setEnabled(false);
rlContainer.setClickable(false);
rlContainer.setAlpha(0.7f);
rlContainer.setBackground(null);
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
rlContainer.setElevation(0);
}
}
ivCross.setOnClickListener(v -> {
if(listener != null) {
listener.onThumbnailDeleted(position);
}
});
}
}
/**
* Callback used to get the current selected file position
*/
interface Callback {
int getCurrentSelectedFilePosition();
}
/**
* Interface to listen to thumbnail delete events
*/
public interface OnThumbnailDeletedListener {
void onThumbnailDeleted(int position);
}
}

View file

@ -0,0 +1,90 @@
package fr.free.nrw.commons.upload
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.facebook.drawee.view.SimpleDraweeView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.ItemUploadThumbnailBinding
import fr.free.nrw.commons.filepicker.UploadableFile
import java.io.File
/**
* The adapter class for image thumbnails to be shown while uploading.
*/
internal class ThumbnailsAdapter(private val callback: Callback) :
RecyclerView.Adapter<ThumbnailsAdapter.ViewHolder>() {
var onThumbnailDeletedListener: OnThumbnailDeletedListener? = null
var uploadableFiles: List<UploadableFile> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int) = ViewHolder(
ItemUploadThumbnailBinding.inflate(
LayoutInflater.from(viewGroup.context), viewGroup, false
)
)
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) = viewHolder.bind(position)
override fun getItemCount(): Int = uploadableFiles.size
inner class ViewHolder(val binding: ItemUploadThumbnailBinding) :
RecyclerView.ViewHolder(binding.root) {
private val rlContainer: RelativeLayout = binding.rlContainer
private val background: SimpleDraweeView = binding.ivThumbnail
private val ivError: ImageView = binding.ivError
private val ivCross: ImageView = binding.icCross
/**
* Binds a row item to the ViewHolder
*/
fun bind(position: Int) {
val uploadableFile = uploadableFiles[position]
val uri = uploadableFile.getMediaUri()
background.setImageURI(Uri.fromFile(File(uri.toString())))
if (position == callback.getCurrentSelectedFilePosition()) {
val border = GradientDrawable()
border.shape = GradientDrawable.RECTANGLE
border.setStroke(8, ContextCompat.getColor(itemView.context, R.color.primaryColor))
rlContainer.isEnabled = true
rlContainer.isClickable = true
rlContainer.alpha = 1.0f
rlContainer.background = border
rlContainer.elevation = 10f
} else {
rlContainer.isEnabled = false
rlContainer.isClickable = false
rlContainer.alpha = 0.7f
rlContainer.background = null
rlContainer.elevation = 0f
}
ivCross.setOnClickListener {
onThumbnailDeletedListener?.onThumbnailDeleted(position)
}
}
}
/**
* Callback used to get the current selected file position
*/
internal fun interface Callback {
fun getCurrentSelectedFilePosition(): Int
}
/**
* Interface to listen to thumbnail delete events
*/
fun interface OnThumbnailDeletedListener {
fun onThumbnailDeleted(position: Int)
}
}

View file

@ -448,7 +448,6 @@ public class UploadActivity extends BaseActivity implements
}
private void receiveSharedItems() {
ThumbnailsAdapter.context=this;
final Intent intent = getIntent();
final String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {

View file

@ -1,41 +0,0 @@
package fr.free.nrw.commons.upload;
import android.os.Bundle;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
/**
* The base fragment of the fragments in upload
*/
public class UploadBaseFragment extends CommonsDaggerSupportFragment {
public Callback callback;
public static final String CALLBACK = "callback";
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
public void setCallback(Callback callback) {
this.callback = callback;
}
protected void onBecameVisible() {
}
public interface Callback {
void onNextButtonClicked(int index);
void onPreviousButtonClicked(int index);
void showProgress(boolean shouldShow);
int getIndexInViewFlipper(UploadBaseFragment fragment);
int getTotalNumberOfSteps();
boolean isWLMUpload();
}
}

View file

@ -0,0 +1,26 @@
package fr.free.nrw.commons.upload
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
/**
* The base fragment of the fragments in upload
*/
abstract class UploadBaseFragment : CommonsDaggerSupportFragment() {
lateinit var callback: Callback
protected open fun onBecameVisible() = Unit
interface Callback {
val totalNumberOfSteps: Int
val isWLMUpload: Boolean
fun onNextButtonClicked(index: Int)
fun onPreviousButtonClicked(index: Int)
fun showProgress(shouldShow: Boolean)
fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int
}
companion object {
const val CALLBACK: String = "callback"
}
}

View file

@ -1,163 +0,0 @@
package fr.free.nrw.commons.upload;
import android.accounts.Account;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import android.text.TextUtils;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.utils.ViewUtil;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
import javax.inject.Inject;
import javax.inject.Singleton;
import timber.log.Timber;
@Singleton
public class UploadController {
private final SessionManager sessionManager;
private final Context context;
private final JsonKvStore store;
@Inject
public UploadController(final SessionManager sessionManager,
final Context context,
final JsonKvStore store) {
this.sessionManager = sessionManager;
this.context = context;
this.store = store;
}
/**
* Starts a new upload task.
*
* @param contribution the contribution object
*/
@SuppressLint("StaticFieldLeak")
public void prepareMedia(final Contribution contribution) {
//Set creator, desc, and license
// If author name is enabled and set, use it
final Media media = contribution.getMedia();
if (store.getBoolean("useAuthorName", false)) {
final String authorName = store.getString("authorName", "");
media.setAuthor(authorName);
}
if (TextUtils.isEmpty(media.getAuthor())) {
final Account currentAccount = sessionManager.getCurrentAccount();
if (currentAccount == null) {
Timber.d("Current account is null");
ViewUtil.showLongToast(context, context.getString(R.string.user_not_logged_in));
sessionManager.forceLogin(context);
return;
}
media.setAuthor(sessionManager.getUserName());
}
if (media.getFallbackDescription() == null) {
media.setFallbackDescription("");
}
final String license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3);
media.setLicense(license);
buildUpload(contribution);
}
/**
* Make the Contribution object ready to be uploaded
* @param contribution
* @return
*/
private void buildUpload(final Contribution contribution) {
final ContentResolver contentResolver = context.getContentResolver();
contribution.setDataLength(resolveDataLength(contentResolver, contribution));
final String mimeType = resolveMimeType(contentResolver, contribution);
if (mimeType != null) {
Timber.d("MimeType is: %s", mimeType);
contribution.setMimeType(mimeType);
if(mimeType.startsWith("image/") && contribution.getDateCreated() == null){
contribution.setDateCreated(resolveDateTakenOrNow(contentResolver, contribution));
}
}
}
private String resolveMimeType(final ContentResolver contentResolver, final Contribution contribution) {
final String mimeType = contribution.getMimeType();
if (mimeType == null || TextUtils.isEmpty(mimeType) || mimeType.endsWith("*")) {
return contentResolver.getType(contribution.getLocalUri());
}
return mimeType;
}
private long resolveDataLength(final ContentResolver contentResolver, final Contribution contribution) {
try {
if (contribution.getDataLength() <= 0) {
Timber.d("UploadController/doInBackground, contribution.getLocalUri():%s", contribution.getLocalUri());
final AssetFileDescriptor assetFileDescriptor = contentResolver
.openAssetFileDescriptor(Uri.fromFile(new File(contribution.getLocalUri().getPath())), "r");
if (assetFileDescriptor != null) {
final long length = assetFileDescriptor.getLength();
return length != -1 ? length
: countBytes(contentResolver.openInputStream(contribution.getLocalUri()));
}
}
} catch (final IOException | NullPointerException | SecurityException e) {
Timber.e(e, "Exception occurred while uploading image");
}
return contribution.getDataLength();
}
private Date resolveDateTakenOrNow(final ContentResolver contentResolver, final Contribution contribution) {
Timber.d("local uri %s", contribution.getLocalUri());
try(final Cursor cursor = dateTakenCursor(contentResolver, contribution)) {
if (cursor != null && cursor.getCount() != 0 && cursor.getColumnCount() != 0) {
cursor.moveToFirst();
final Date dateCreated = new Date(cursor.getLong(0));
if (dateCreated.after(new Date(0))) {
return dateCreated;
}
}
return new Date();
}
}
private Cursor dateTakenCursor(final ContentResolver contentResolver, final Contribution contribution) {
return contentResolver.query(contribution.getLocalUri(),
new String[]{MediaStore.Images.ImageColumns.DATE_TAKEN}, null, null, null);
}
/**
* Counts the number of bytes in {@code stream}.
*
* @param stream the stream
* @return the number of bytes in {@code stream}
* @throws IOException if an I/O error occurs
*/
private long countBytes(final InputStream stream) throws IOException {
long count = 0;
final BufferedInputStream bis = new BufferedInputStream(stream);
while (bis.read() != -1) {
count++;
}
return count;
}
}

View file

@ -0,0 +1,166 @@
package fr.free.nrw.commons.upload
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import android.text.TextUtils
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import timber.log.Timber
import java.io.BufferedInputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UploadController @Inject constructor(
private val sessionManager: SessionManager,
private val context: Context,
private val store: JsonKvStore
) {
/**
* Starts a new upload task.
*
* @param contribution the contribution object
*/
@SuppressLint("StaticFieldLeak")
fun prepareMedia(contribution: Contribution) {
//Set creator, desc, and license
// If author name is enabled and set, use it
val media = contribution.media
if (store.getBoolean("useAuthorName", false)) {
val authorName = store.getString("authorName", "")
media.author = authorName
}
if (media.author.isNullOrEmpty()) {
val currentAccount = sessionManager.currentAccount
if (currentAccount == null) {
Timber.d("Current account is null")
showLongToast(context, context.getString(R.string.user_not_logged_in))
sessionManager.forceLogin(context)
return
}
media.author = sessionManager.userName
}
if (media.fallbackDescription == null) {
media.fallbackDescription = ""
}
val license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3)
media.license = license
buildUpload(contribution)
}
private fun buildUpload(contribution: Contribution) {
val contentResolver = context.contentResolver
contribution.dataLength = resolveDataLength(contentResolver, contribution)
val mimeType = resolveMimeType(contentResolver, contribution)
if (mimeType != null) {
Timber.d("MimeType is: %s", mimeType)
contribution.mimeType = mimeType
if (mimeType.startsWith("image/") && contribution.dateCreated == null) {
contribution.dateCreated = resolveDateTakenOrNow(contentResolver, contribution)
}
}
}
private fun resolveMimeType(
contentResolver: ContentResolver,
contribution: Contribution
): String? {
val mimeType: String? = contribution.mimeType
return if (mimeType.isNullOrEmpty() || mimeType.endsWith("*")) {
contentResolver.getType(contribution.localUri!!)
} else {
mimeType
}
}
private fun resolveDataLength(
contentResolver: ContentResolver,
contribution: Contribution
): Long {
try {
if (contribution.dataLength <= 0) {
Timber.d(
"UploadController/doInBackground, contribution.getLocalUri():%s",
contribution.localUri
)
contentResolver.openAssetFileDescriptor(
Uri.fromFile(File(contribution.localUri!!.path!!)), "r"
)?.use {
return if (it.length != -1L) it.length
else countBytes(contentResolver.openInputStream(contribution.localUri))
}
}
} catch (e: IOException) {
Timber.e(e, "Exception occurred while uploading image")
} catch (e: NullPointerException) {
Timber.e(e, "Exception occurred while uploading image")
} catch (e: SecurityException) {
Timber.e(e, "Exception occurred while uploading image")
}
return contribution.dataLength
}
private fun resolveDateTakenOrNow(
contentResolver: ContentResolver,
contribution: Contribution
): Date {
Timber.d("local uri %s", contribution.localUri)
dateTakenCursor(contentResolver, contribution).use { cursor ->
if (cursor != null && cursor.count != 0 && cursor.columnCount != 0) {
cursor.moveToFirst()
val dateCreated = Date(cursor.getLong(0))
if (dateCreated.after(Date(0))) {
return dateCreated
}
}
return Date()
}
}
private fun dateTakenCursor(
contentResolver: ContentResolver,
contribution: Contribution
): Cursor? = contentResolver.query(
contribution.localUri!!,
arrayOf(MediaStore.Images.ImageColumns.DATE_TAKEN), null, null, null
)
/**
* Counts the number of bytes in `stream`.
*
* @param stream the stream
* @return the number of bytes in `stream`
* @throws IOException if an I/O error occurs
*/
@Throws(IOException::class)
private fun countBytes(stream: InputStream?): Long {
var count: Long = 0
val bis = BufferedInputStream(stream)
while (bis.read() != -1) {
count++
}
return count
}
}

View file

@ -1,175 +0,0 @@
package fr.free.nrw.commons.upload;
import android.annotation.SuppressLint;
import android.net.Uri;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.filepicker.MimeTypeMapWrapper;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.utils.ImageUtils;
import io.reactivex.subjects.BehaviorSubject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class UploadItem {
private Uri mediaUri;
private final String mimeType;
private ImageCoordinates gpsCoords;
private List<UploadMediaDetail> uploadMediaDetails;
private Place place;
private final long createdTimestamp;
private final String createdTimestampSource;
private final BehaviorSubject<Integer> imageQuality;
private boolean hasInvalidLocation;
private boolean isWLMUpload = false;
private String countryCode;
private String fileCreatedDateString; //according to EXIF data
/**
* Uri of uploadItem
* Uri points to image location or name, eg content://media/external/images/camera/10495 (Android 10)
*/
private Uri contentUri;
@SuppressLint("CheckResult")
UploadItem(final Uri mediaUri,
final String mimeType,
final ImageCoordinates gpsCoords,
final Place place,
final long createdTimestamp,
final String createdTimestampSource,
final Uri contentUri,
final String fileCreatedDateString) {
this.createdTimestampSource = createdTimestampSource;
uploadMediaDetails = new ArrayList<>(Collections.singletonList(new UploadMediaDetail()));
this.place = place;
this.mediaUri = mediaUri;
this.mimeType = mimeType;
this.gpsCoords = gpsCoords;
this.createdTimestamp = createdTimestamp;
this.contentUri = contentUri;
imageQuality = BehaviorSubject.createDefault(ImageUtils.IMAGE_WAIT);
this.fileCreatedDateString = fileCreatedDateString;
}
public String getCreatedTimestampSource() {
return createdTimestampSource;
}
public ImageCoordinates getGpsCoords() {
return gpsCoords;
}
public List<UploadMediaDetail> getUploadMediaDetails() {
return uploadMediaDetails;
}
public long getCreatedTimestamp() {
return createdTimestamp;
}
public Uri getMediaUri() {
return mediaUri;
}
public int getImageQuality() {
return imageQuality.getValue();
}
/**
* getContentUri.
* @return Uri of uploadItem
* Uri points to image location or name, eg content://media/external/images/camera/10495 (Android 10)
*/
public Uri getContentUri() { return contentUri; }
public String getFileCreatedDateString() { return fileCreatedDateString; }
public void setImageQuality(final int imageQuality) {
this.imageQuality.onNext(imageQuality);
}
/**
* Sets the corresponding place to the uploadItem
*
* @param place geolocated Wikidata item
*/
public void setPlace(Place place) {
this.place = place;
}
public Place getPlace() {
return place;
}
public void setMediaDetails(final List<UploadMediaDetail> uploadMediaDetails) {
this.uploadMediaDetails = uploadMediaDetails;
}
public void setWLMUpload(final boolean WLMUpload) {
isWLMUpload = WLMUpload;
}
public boolean isWLMUpload() {
return isWLMUpload;
}
@Override
public boolean equals(@Nullable final Object obj) {
if (!(obj instanceof UploadItem)) {
return false;
}
return mediaUri.toString().contains(((UploadItem) (obj)).mediaUri.toString());
}
@Override
public int hashCode() {
return mediaUri.hashCode();
}
/**
* Choose a filename for the media. Currently, the caption is used as a filename. If several
* languages have been entered, the first language is used.
*/
public String getFileName() {
return Utils.fixExtension(uploadMediaDetails.get(0).getCaptionText(),
MimeTypeMapWrapper.getExtensionFromMimeType(mimeType));
}
public void setGpsCoords(final ImageCoordinates gpsCoords) {
this.gpsCoords = gpsCoords;
}
public void setHasInvalidLocation(boolean hasInvalidLocation) {
this.hasInvalidLocation = hasInvalidLocation;
}
public boolean hasInvalidLocation() {
return hasInvalidLocation;
}
public void setCountryCode(final String countryCode) {
this.countryCode = countryCode;
}
@Nullable
public String getCountryCode() {
return countryCode;
}
/**
* Sets both the contentUri and mediaUri to the specified Uri.
* This method allows you to assign the same Uri to both the contentUri and mediaUri
* properties.
*
* @param uri The Uri to be set as both the contentUri and mediaUri.
*/
public void setContentUri(Uri uri) {
contentUri = uri;
mediaUri = uri;
}
}

View file

@ -0,0 +1,65 @@
package fr.free.nrw.commons.upload
import android.net.Uri
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.filepicker.MimeTypeMapWrapper.Companion.getExtensionFromMimeType
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.utils.ImageUtils
import io.reactivex.subjects.BehaviorSubject
class UploadItem(
var mediaUri: Uri?,
val mimeType: String?,
var gpsCoords: ImageCoordinates?,
var place: Place?,
val createdTimestamp: Long?,
val createdTimestampSource: String?,
/**
* Uri of uploadItem
* Uri points to image location or name, eg content://media/external/images/camera/10495 (Android 10)
*/
var contentUri: Uri?,
//according to EXIF data
val fileCreatedDateString: String?
) {
var imageQuality: Int = ImageUtils.IMAGE_WAIT
var uploadMediaDetails: MutableList<UploadMediaDetail> = mutableListOf(UploadMediaDetail())
var hasInvalidLocation = false
var isWLMUpload = false
var countryCode: String? = null
/**
* Choose a filename for the media. Currently, the caption is used as a filename. If several
* languages have been entered, the first language is used.
*/
val filename: String
get() = Utils.fixExtension(
uploadMediaDetails[0].captionText,
getExtensionFromMimeType(mimeType)
)
fun hasInvalidLocation(): Boolean = hasInvalidLocation
/**
* Sets both the contentUri and mediaUri to the specified Uri.
* This method allows you to assign the same Uri to both the contentUri and mediaUri
* properties.
*
* @param uri The Uri to be set as both the contentUri and mediaUri.
*/
fun setContentAndMediaUri(uri: Uri) {
contentUri = uri
mediaUri = uri
}
override fun equals(other: Any?): Boolean {
if (other !is UploadItem) {
return false
}
return mediaUri.toString().contains((other).mediaUri.toString())
}
override fun hashCode(): Int {
return mediaUri.hashCode()
}
}

View file

@ -1,81 +0,0 @@
package fr.free.nrw.commons.upload;
import android.text.InputFilter;
import android.text.Spanned;
import java.util.regex.Pattern;
/**
* An {@link InputFilter} class that removes characters blocklisted in Wikimedia titles. The list
* of blocklisted characters is linked below.
* @see <a href="https://commons.wikimedia.org/wiki/MediaWiki:Titleblacklist"></a>wikimedia.org</a>
*/
public class UploadMediaDetailInputFilter implements InputFilter {
private final Pattern[] patterns;
/**
* Initializes the blocklisted patterns.
*/
public UploadMediaDetailInputFilter() {
patterns = new Pattern[]{
Pattern.compile("[\\x{00A0}\\x{1680}\\x{180E}\\x{2000}-\\x{200B}\\x{2028}\\x{2029}\\x{202F}\\x{205F}]"),
Pattern.compile("[\\x{202A}-\\x{202E}]"),
Pattern.compile("\\p{Cc}"),
Pattern.compile("\\x{3A}"), // Added for colon(:)
Pattern.compile("\\x{FEFF}"),
Pattern.compile("\\x{00AD}"),
Pattern.compile("[\\x{E000}-\\x{F8FF}\\x{FFF0}-\\x{FFFF}]"),
Pattern.compile("[^\\x{0000}-\\x{FFFF}\\p{sc=Han}]")
};
}
/**
* Checks if the source text contains any blocklisted characters.
* @param source input text
* @return contains a blocklisted character
*/
private Boolean checkBlocklisted(final CharSequence source) {
for (final Pattern pattern: patterns) {
if (pattern.matcher(source).find()) {
return true;
}
}
return false;
}
/**
* Removes any blocklisted characters from the source text.
* @param source input text
* @return a cleaned character sequence
*/
private CharSequence removeBlocklisted(CharSequence source) {
for (final Pattern pattern: patterns) {
source = pattern.matcher(source).replaceAll("");
}
return source;
}
/**
* Filters out any blocklisted characters.
* @param source {@inheritDoc}
* @param start {@inheritDoc}
* @param end {@inheritDoc}
* @param dest {@inheritDoc}
* @param dstart {@inheritDoc}
* @param dend {@inheritDoc}
* @return {@inheritDoc}
*/
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart,
int dend) {
if (checkBlocklisted(source)) {
if (start == dstart && dest.length() > 0) {
return dest;
}
return removeBlocklisted(source);
}
return null;
}
}

View file

@ -0,0 +1,69 @@
package fr.free.nrw.commons.upload
import android.text.InputFilter
import android.text.Spanned
import java.util.regex.Pattern
/**
* An [InputFilter] class that removes characters blocklisted in Wikimedia titles. The list
* of blocklisted characters is linked below.
* @see [](https://commons.wikimedia.org/wiki/MediaWiki:Titleblacklist)wikimedia.org
*/
class UploadMediaDetailInputFilter : InputFilter {
private val patterns = listOf(
Pattern.compile("[\\x{00A0}\\x{1680}\\x{180E}\\x{2000}-\\x{200B}\\x{2028}\\x{2029}\\x{202F}\\x{205F}]"),
Pattern.compile("[\\x{202A}-\\x{202E}]"),
Pattern.compile("\\p{Cc}"),
Pattern.compile("\\x{3A}"), // Added for colon(:)
Pattern.compile("\\x{FEFF}"),
Pattern.compile("\\x{00AD}"),
Pattern.compile("[\\x{E000}-\\x{F8FF}\\x{FFF0}-\\x{FFFF}]"),
Pattern.compile("[^\\x{0000}-\\x{FFFF}\\p{sc=Han}]")
)
/**
* Checks if the source text contains any blocklisted characters.
* @param source input text
* @return contains a blocklisted character
*/
private fun checkBlocklisted(source: CharSequence): Boolean =
patterns.any { it.matcher(source).find() }
/**
* Removes any blocklisted characters from the source text.
* @param source input text
* @return a cleaned character sequence
*/
private fun removeBlocklisted(input: CharSequence): CharSequence {
var source = input
patterns.forEach {
source = it.matcher(source).replaceAll("")
}
return source
}
/**
* Filters out any blocklisted characters.
* @param source {@inheritDoc}
* @param start {@inheritDoc}
* @param end {@inheritDoc}
* @param dest {@inheritDoc}
* @param dstart {@inheritDoc}
* @param dend {@inheritDoc}
* @return {@inheritDoc}
*/
override fun filter(
source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int,
dend: Int
): CharSequence? {
if (checkBlocklisted(source)) {
if (start == dstart && dest.isNotEmpty()) {
return dest
}
return removeBlocklisted(source)
}
return null
}
}

View file

@ -1,206 +0,0 @@
package fr.free.nrw.commons.upload;
import android.annotation.SuppressLint;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract;
import io.reactivex.Observer;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import java.lang.reflect.Proxy;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import timber.log.Timber;
/**
* The MVP pattern presenter of Upload GUI
*/
@Singleton
public class UploadPresenter implements UploadContract.UserActionListener {
private static final UploadContract.View DUMMY = (UploadContract.View) Proxy.newProxyInstance(
UploadContract.View.class.getClassLoader(),
new Class[]{UploadContract.View.class}, (proxy, method, methodArgs) -> null);
private final UploadRepository repository;
private final JsonKvStore defaultKvStore;
private UploadContract.View view = DUMMY;
@Inject
UploadMediaDetailsContract.UserActionListener presenter;
private CompositeDisposable compositeDisposable;
public static final String COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES
= "number_of_consecutive_uploads_without_coordinates";
public static final int CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES_REMINDER_THRESHOLD = 10;
@Inject
UploadPresenter(UploadRepository uploadRepository,
@Named("default_preferences") JsonKvStore defaultKvStore) {
this.repository = uploadRepository;
this.defaultKvStore = defaultKvStore;
compositeDisposable = new CompositeDisposable();
}
/**
* Called by the submit button in {@link UploadActivity}
*/
@SuppressLint("CheckResult")
@Override
public void handleSubmit() {
boolean hasLocationProvidedForNewUploads = false;
for (UploadItem item : repository.getUploads()) {
if (item.getGpsCoords().getImageCoordsExists()) {
hasLocationProvidedForNewUploads = true;
}
}
boolean hasManyConsecutiveUploadsWithoutLocation = defaultKvStore.getInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0) >=
CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES_REMINDER_THRESHOLD;
if (hasManyConsecutiveUploadsWithoutLocation && !hasLocationProvidedForNewUploads) {
defaultKvStore.putInt(COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0);
view.showAlertDialog(
R.string.location_message,
() -> {defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES,
0);
processContributionsForSubmission();
});
} else {
processContributionsForSubmission();
}
}
private void processContributionsForSubmission() {
if (view.isLoggedIn()) {
view.showProgress(true);
repository.buildContributions()
.observeOn(Schedulers.io())
.subscribe(new Observer<Contribution>() {
@Override
public void onSubscribe(Disposable d) {
view.showProgress(false);
if (defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED,
false)) {
view.showMessage(R.string.uploading_queued);
} else {
view.showMessage(R.string.uploading_started);
}
compositeDisposable.add(d);
}
@Override
public void onNext(Contribution contribution) {
if (contribution.getDecimalCoords() == null) {
final int recentCount = defaultKvStore.getInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0);
defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, recentCount + 1);
} else {
defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0);
}
repository.prepareMedia(contribution);
contribution.setState(Contribution.STATE_QUEUED);
repository.saveContribution(contribution);
}
@Override
public void onError(Throwable e) {
view.showMessage(R.string.upload_failed);
repository.cleanup();
view.returnToMainActivity();
compositeDisposable.clear();
Timber.e("failed to upload: " + e.getMessage());
//is submission error, not need to go to the uploadActivity
//not start the uploading progress
}
@Override
public void onComplete() {
view.makeUploadRequest();
repository.cleanup();
view.returnToMainActivity();
compositeDisposable.clear();
//after finish the uploadActivity, if successful,
//directly go to the upload progress activity
view.goToUploadProgressActivity();
}
});
} else {
view.askUserToLogIn();
}
}
/**
* Calls checkImageQuality of UploadMediaPresenter to check image quality of next image
*
* @param uploadItemIndex Index of next image, whose quality is to be checked
*/
@Override
public void checkImageQuality(int uploadItemIndex) {
UploadItem uploadItem = repository.getUploadItem(uploadItemIndex);
presenter.checkImageQuality(uploadItem, uploadItemIndex);
}
@Override
public void deletePictureAtIndex(int index) {
List<UploadableFile> uploadableFiles = view.getUploadableFiles();
if (index == uploadableFiles.size() - 1) {
// If the next fragment to be shown is not one of the MediaDetailsFragment
// lets hide the top card so that it doesn't appear on the other fragments
view.showHideTopCard(false);
}
view.setImageCancelled(true);
repository.deletePicture(uploadableFiles.get(index).getFilePath());
if (uploadableFiles.size() == 1) {
view.showMessage(R.string.upload_cancelled);
view.finish();
return;
} else {
if (presenter != null) {
presenter.updateImageQualitiesJSON(uploadableFiles.size(), index);
}
view.onUploadMediaDeleted(index);
if (!(index == uploadableFiles.size()) && index != 0) {
// if the deleted image was not the last item to be uploaded, check quality of next
UploadItem uploadItem = repository.getUploadItem(index);
presenter.checkImageQuality(uploadItem, index);
}
}
if (uploadableFiles.size() < 2) {
view.showHideTopCard(false);
}
//In case lets update the number of uploadable media
view.updateTopCardTitle();
}
@Override
public void onAttachView(UploadContract.View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
compositeDisposable.clear();
repository.cleanup();
}
}

View file

@ -0,0 +1,192 @@
package fr.free.nrw.commons.upload
import android.annotation.SuppressLint
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.CommonsApplication.Companion.IS_LIMITED_CONNECTION_MODE_ENABLED
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract
import io.reactivex.Observer
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* The MVP pattern presenter of Upload GUI
*/
@Singleton
class UploadPresenter @Inject internal constructor(
private val repository: UploadRepository,
@param:Named("default_preferences") private val defaultKvStore: JsonKvStore
) : UploadContract.UserActionListener {
private var view = DUMMY
@Inject
lateinit var presenter: UploadMediaDetailsContract.UserActionListener
private val compositeDisposable = CompositeDisposable()
/**
* Called by the submit button in [UploadActivity]
*/
@SuppressLint("CheckResult")
override fun handleSubmit() {
var hasLocationProvidedForNewUploads = false
for (item in repository.getUploads()) {
if (item.gpsCoords?.imageCoordsExists == true) {
hasLocationProvidedForNewUploads = true
}
}
val hasManyConsecutiveUploadsWithoutLocation = defaultKvStore.getInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0
) >=
CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES_REMINDER_THRESHOLD
if (hasManyConsecutiveUploadsWithoutLocation && !hasLocationProvidedForNewUploads) {
defaultKvStore.putInt(COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0)
view.showAlertDialog(
R.string.location_message
) {
defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES,
0
)
processContributionsForSubmission()
}
} else {
processContributionsForSubmission()
}
}
private fun processContributionsForSubmission() {
if (view.isLoggedIn()) {
view.showProgress(true)
repository.buildContributions()
?.observeOn(Schedulers.io())
?.subscribe(object : Observer<Contribution> {
override fun onSubscribe(d: Disposable) {
view.showProgress(false)
if (defaultKvStore.getBoolean(IS_LIMITED_CONNECTION_MODE_ENABLED, false)) {
view.showMessage(R.string.uploading_queued)
} else {
view.showMessage(R.string.uploading_started)
}
compositeDisposable.add(d)
}
override fun onNext(contribution: Contribution) {
if (contribution.decimalCoords == null) {
val recentCount = defaultKvStore.getInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0
)
defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, recentCount + 1
)
} else {
defaultKvStore.putInt(
COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0
)
}
repository.prepareMedia(contribution)
contribution.state = Contribution.STATE_QUEUED
repository.saveContribution(contribution)
}
override fun onError(e: Throwable) {
view.showMessage(R.string.upload_failed)
repository.cleanup()
view.returnToMainActivity()
compositeDisposable.clear()
Timber.e(e, "failed to upload")
//is submission error, not need to go to the uploadActivity
//not start the uploading progress
}
override fun onComplete() {
view.makeUploadRequest()
repository.cleanup()
view.returnToMainActivity()
compositeDisposable.clear()
//after finish the uploadActivity, if successful,
//directly go to the upload progress activity
view.goToUploadProgressActivity()
}
})
} else {
view.askUserToLogIn()
}
}
/**
* Calls checkImageQuality of UploadMediaPresenter to check image quality of next image
*
* @param uploadItemIndex Index of next image, whose quality is to be checked
*/
override fun checkImageQuality(uploadItemIndex: Int) {
val uploadItem = repository.getUploadItem(uploadItemIndex)
presenter.checkImageQuality(uploadItem, uploadItemIndex)
}
override fun deletePictureAtIndex(index: Int) {
val uploadableFiles = view.getUploadableFiles()
if (index == uploadableFiles!!.size - 1) {
// If the next fragment to be shown is not one of the MediaDetailsFragment
// lets hide the top card so that it doesn't appear on the other fragments
view.showHideTopCard(false)
}
view.setImageCancelled(true)
repository.deletePicture(uploadableFiles[index].getFilePath())
if (uploadableFiles.size == 1) {
view.showMessage(R.string.upload_cancelled)
view.finish()
return
}
presenter.updateImageQualitiesJSON(uploadableFiles.size, index)
view.onUploadMediaDeleted(index)
if (index != uploadableFiles.size && index != 0) {
// if the deleted image was not the last item to be uploaded, check quality of next
val uploadItem = repository.getUploadItem(index)
presenter.checkImageQuality(uploadItem, index)
}
if (uploadableFiles.size < 2) {
view.showHideTopCard(false)
}
//In case lets update the number of uploadable media
view.updateTopCardTitle()
}
override fun onAttachView(view: UploadContract.View) {
this.view = view
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
repository.cleanup()
}
companion object {
private val DUMMY = Proxy.newProxyInstance(
UploadContract.View::class.java.classLoader,
arrayOf<Class<*>>(UploadContract.View::class.java)
) { _: Any?, _: Method?, _: Array<Any?>? -> null } as UploadContract.View
const val COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES: String =
"number_of_consecutive_uploads_without_coordinates"
const val CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES_REMINDER_THRESHOLD: Int = 10
}
}

View file

@ -1,425 +0,0 @@
package fr.free.nrw.commons.upload.categories;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.os.Bundle;
import android.text.Editable;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.jakewharton.rxbinding2.view.RxView;
import com.jakewharton.rxbinding2.widget.RxTextView;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.category.CategoryItem;
import fr.free.nrw.commons.contributions.ContributionsFragment;
import fr.free.nrw.commons.databinding.UploadCategoriesFragmentBinding;
import fr.free.nrw.commons.media.MediaDetailFragment;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadBaseFragment;
import fr.free.nrw.commons.utils.DialogUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import kotlin.Unit;
import timber.log.Timber;
public class UploadCategoriesFragment extends UploadBaseFragment implements CategoriesContract.View {
@Inject
CategoriesContract.UserActionListener presenter;
@Inject
SessionManager sessionManager;
private UploadCategoryAdapter adapter;
private Disposable subscribe;
/**
* Current media
*/
private Media media;
/**
* Progress Dialog for showing background process
*/
private ProgressDialog progressDialog;
/**
* WikiText from the server
*/
private String wikiText;
private String nearbyPlaceCategory;
private UploadCategoriesFragmentBinding binding;
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = UploadCategoriesFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
final Bundle bundle = getArguments();
if (bundle != null) {
media = bundle.getParcelable("Existing_Categories");
wikiText = bundle.getString("WikiText");
nearbyPlaceCategory = bundle.getString(SELECTED_NEARBY_PLACE_CATEGORY);
}
init();
presenter.getCategories().observe(getViewLifecycleOwner(), this::setCategories);
}
private void init() {
if (binding == null) {
return;
}
if (media == null) {
if (callback != null) {
binding.tvTitle.setText(getString(R.string.step_count, callback.getIndexInViewFlipper(this) + 1,
callback.getTotalNumberOfSteps(), getString(R.string.categories_activity_title)));
}
} else {
binding.tvTitle.setText(R.string.edit_categories);
binding.tvSubtitle.setVisibility(View.GONE);
binding.btnNext.setText(R.string.menu_save_categories);
binding.btnPrevious.setText(R.string.menu_cancel_upload);
}
setTvSubTitle();
binding.tooltip.setOnClickListener(new OnClickListener() {
@Override
public void onClick(final View v) {
DialogUtil.showAlertDialog(requireActivity(),
getString(R.string.categories_activity_title),
getString(R.string.categories_tooltip),
getString(android.R.string.ok),
null);
}
});
if (media == null) {
presenter.onAttachView(this);
} else {
presenter.onAttachViewWithMedia(this, media);
}
binding.btnNext.setOnClickListener(v -> onNextButtonClicked());
binding.btnPrevious.setOnClickListener(v -> onPreviousButtonClicked());
initRecyclerView();
addTextChangeListenerToEtSearch();
}
private void addTextChangeListenerToEtSearch() {
if (binding == null) {
return;
}
subscribe = RxTextView.textChanges(binding.etSearch)
.doOnEach(v -> binding.tilContainerSearch.setError(null))
.takeUntil(RxView.detaches(binding.etSearch))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(filter -> searchForCategory(filter.toString()), Timber::e);
}
/**
* Removes the tv subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private void setTvSubTitle() {
final Activity activity = getActivity();
if (activity instanceof UploadActivity) {
final boolean isMultipleFileSelected = ((UploadActivity) activity).getIsMultipleFilesSelected();
if (!isMultipleFileSelected) {
binding.tvSubtitle.setVisibility(View.GONE);
}
}
}
private void searchForCategory(final String query) {
presenter.searchForCategories(query);
}
private void initRecyclerView() {
adapter = new UploadCategoryAdapter(categoryItem -> {
presenter.onCategoryItemClicked(categoryItem);
return Unit.INSTANCE;
}, nearbyPlaceCategory);
if (binding!=null) {
binding.rvCategories.setLayoutManager(new LinearLayoutManager(getContext()));
binding.rvCategories.setAdapter(adapter);
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
presenter.onDetachView();
subscribe.dispose();
}
@Override
public void showProgress(final boolean shouldShow) {
if (binding != null) {
binding.pbCategories.setVisibility(shouldShow ? View.VISIBLE : View.GONE);
}
}
@Override
public void showError(final String error) {
if (binding != null) {
binding.tilContainerSearch.setError(error);
}
}
@Override
public void showError(final int stringResourceId) {
if (binding != null) {
binding.tilContainerSearch.setError(getString(stringResourceId));
}
}
@Override
public void setCategories(final List<CategoryItem> categories) {
if (categories == null) {
adapter.clear();
} else {
adapter.setItems(categories);
}
adapter.notifyDataSetChanged();
if (binding == null) {
return;
}
// Nested waiting for search result data to load into the category
// list and smoothly scroll to the top of the search result list.
binding.rvCategories.post(new Runnable() {
@Override
public void run() {
binding.rvCategories.smoothScrollToPosition(0);
binding.rvCategories.post(new Runnable() {
@Override
public void run() {
binding.rvCategories.smoothScrollToPosition(0);
}
});
}
});
}
@Override
public void goToNextScreen() {
if (callback != null){
callback.onNextButtonClicked(callback.getIndexInViewFlipper(this));
}
}
@Override
public void showNoCategorySelected() {
if (media == null) {
DialogUtil.showAlertDialog(requireActivity(),
getString(R.string.no_categories_selected),
getString(R.string.no_categories_selected_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel),
this::goToNextScreen,
null);
} else {
Toast.makeText(requireContext(), getString(R.string.no_categories_selected),
Toast.LENGTH_SHORT).show();
presenter.clearPreviousSelection();
goBackToPreviousScreen();
}
}
/**
* Gets existing categories from media
*/
@Override
public List<String> getExistingCategories() {
return (media == null) ? null : media.getCategories();
}
/**
* Returns required context
*/
@NonNull
@Override
public Context getFragmentContext() {
return requireContext();
}
/**
* Returns to previous fragment
*/
@Override
public void goBackToPreviousScreen() {
getFragmentManager().popBackStack();
}
/**
* Shows the progress dialog
*/
@Override
public void showProgressDialog() {
progressDialog = new ProgressDialog(requireContext());
progressDialog.setMessage(getString(R.string.please_wait));
progressDialog.show();
}
/**
* Hides the progress dialog
*/
@Override
public void dismissProgressDialog() {
if (progressDialog != null) {
progressDialog.dismiss();
}
}
/**
* Refreshes the categories
*/
@Override
public void refreshCategories() {
final MediaDetailFragment mediaDetailFragment = (MediaDetailFragment) getParentFragment();
assert mediaDetailFragment != null;
mediaDetailFragment.updateCategories();
}
/**
*
*/
@Override
public void navigateToLoginScreen() {
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
requireActivity(),
requireActivity().getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
requireActivity(), logoutListener);
}
public void onNextButtonClicked() {
if (media != null) {
presenter.updateCategories(media, wikiText);
} else {
presenter.verifyCategories();
}
}
public void onPreviousButtonClicked() {
if (media != null) {
presenter.clearPreviousSelection();
adapter.setItems(null);
final MediaDetailFragment mediaDetailFragment = (MediaDetailFragment) getParentFragment();
assert mediaDetailFragment != null;
mediaDetailFragment.onResume();
goBackToPreviousScreen();
} else {
if (callback != null) {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this));
}
}
}
@Override
protected void onBecameVisible() {
super.onBecameVisible();
if (binding == null) {
return;
}
presenter.selectCategories();
final Editable text = binding.etSearch.getText();
if (text != null) {
presenter.searchForCategories(text.toString());
}
}
/**
* Hides the action bar while opening editing fragment
*/
@Override
public void onResume() {
super.onResume();
if (media != null) {
binding.etSearch.setOnKeyListener((v, keyCode, event) -> {
if (keyCode == KeyEvent.KEYCODE_BACK) {
binding.etSearch.clearFocus();
presenter.clearPreviousSelection();
final MediaDetailFragment mediaDetailFragment = (MediaDetailFragment) getParentFragment();
assert mediaDetailFragment != null;
mediaDetailFragment.onResume();
goBackToPreviousScreen();
return true;
}
return false;
});
requireView().setFocusableInTouchMode(true);
getView().requestFocus();
getView().setOnKeyListener((v, keyCode, event) -> {
if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
presenter.clearPreviousSelection();
final MediaDetailFragment mediaDetailFragment = (MediaDetailFragment) getParentFragment();
assert mediaDetailFragment != null;
mediaDetailFragment.onResume();
goBackToPreviousScreen();
return true;
}
return false;
});
Objects.requireNonNull(
((AppCompatActivity) requireActivity()).getSupportActionBar())
.hide();
if (getParentFragment().getParentFragment().getParentFragment()
instanceof ContributionsFragment) {
((ContributionsFragment) (getParentFragment()
.getParentFragment().getParentFragment())).binding.cardViewNearby
.setVisibility(View.GONE);
}
}
}
/**
* Shows the action bar while closing editing fragment
*/
@Override
public void onStop() {
super.onStop();
if (media != null) {
Objects.requireNonNull(
((AppCompatActivity) requireActivity()).getSupportActionBar())
.show();
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,397 @@
package fr.free.nrw.commons.upload.categories
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.jakewharton.rxbinding2.view.RxView
import com.jakewharton.rxbinding2.widget.RxTextView
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.CommonsApplication.Companion.instance
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.category.CategoryItem
import fr.free.nrw.commons.contributions.ContributionsFragment
import fr.free.nrw.commons.databinding.UploadCategoriesFragmentBinding
import fr.free.nrw.commons.media.MediaDetailFragment
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadBaseFragment
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY
import io.reactivex.Notification
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import timber.log.Timber
import java.util.Objects
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
@JvmField
@Inject
var presenter: CategoriesContract.UserActionListener? = null
@JvmField
@Inject
var sessionManager: SessionManager? = null
private var adapter: UploadCategoryAdapter? = null
private var subscribe: Disposable? = null
/**
* Current media
*/
private var media: Media? = null
/**
* Progress Dialog for showing background process
*/
private var progressDialog: ProgressDialog? = null
/**
* WikiText from the server
*/
private var wikiText: String? = null
private var nearbyPlaceCategory: String? = null
private var binding: UploadCategoriesFragmentBinding? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = UploadCategoriesFragmentBinding.inflate(inflater, container, false)
return binding!!.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val bundle = arguments
if (bundle != null) {
media = bundle.getParcelable("Existing_Categories")
wikiText = bundle.getString("WikiText")
nearbyPlaceCategory = bundle.getString(SELECTED_NEARBY_PLACE_CATEGORY)
}
init()
presenter!!.getCategories().observe(
viewLifecycleOwner
) { categories: List<CategoryItem>? ->
this.setCategories(
categories
)
}
}
private fun init() {
if (binding == null) {
return
}
if (media == null) {
if (callback != null) {
binding!!.tvTitle.text = getString(
R.string.step_count, callback.getIndexInViewFlipper(
this
) + 1,
callback.totalNumberOfSteps, getString(R.string.categories_activity_title)
)
}
} else {
binding!!.tvTitle.setText(R.string.edit_categories)
binding!!.tvSubtitle.visibility = View.GONE
binding!!.btnNext.setText(R.string.menu_save_categories)
binding!!.btnPrevious.setText(R.string.menu_cancel_upload)
}
setTvSubTitle()
binding!!.tooltip.setOnClickListener {
showAlertDialog(
requireActivity(),
getString(R.string.categories_activity_title),
getString(R.string.categories_tooltip),
getString(android.R.string.ok),
null
)
}
if (media == null) {
presenter!!.onAttachView(this)
} else {
presenter!!.onAttachViewWithMedia(this, media!!)
}
binding!!.btnNext.setOnClickListener { v: View? -> onNextButtonClicked() }
binding!!.btnPrevious.setOnClickListener { v: View? -> onPreviousButtonClicked() }
initRecyclerView()
addTextChangeListenerToEtSearch()
}
private fun addTextChangeListenerToEtSearch() {
if (binding == null) {
return
}
subscribe = RxTextView.textChanges(binding!!.etSearch)
.doOnEach { v: Notification<CharSequence?>? ->
binding!!.tilContainerSearch.error =
null
}
.takeUntil(RxView.detaches(binding!!.etSearch))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ filter: CharSequence -> searchForCategory(filter.toString()) },
{ t: Throwable? -> Timber.e(t) })
}
/**
* Removes the tv subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private fun setTvSubTitle() {
val activity: Activity? = activity
if (activity is UploadActivity) {
val isMultipleFileSelected = activity.isMultipleFilesSelected
if (!isMultipleFileSelected) {
binding!!.tvSubtitle.visibility = View.GONE
}
}
}
private fun searchForCategory(query: String) {
presenter!!.searchForCategories(query)
}
private fun initRecyclerView() {
adapter = UploadCategoryAdapter({ categoryItem: CategoryItem? ->
presenter!!.onCategoryItemClicked(categoryItem!!)
Unit
}, nearbyPlaceCategory)
if (binding != null) {
binding!!.rvCategories.layoutManager = LinearLayoutManager(context)
binding!!.rvCategories.adapter = adapter
}
}
override fun onDestroyView() {
super.onDestroyView()
presenter!!.onDetachView()
subscribe!!.dispose()
}
override fun showProgress(shouldShow: Boolean) {
binding?.pbCategories?.setVisibility(if (shouldShow) View.VISIBLE else View.GONE)
}
override fun showError(error: String?) {
binding?.tilContainerSearch?.error = error
}
override fun showError(stringResourceId: Int) {
binding?.tilContainerSearch?.error = getString(stringResourceId)
}
override fun setCategories(categories: List<CategoryItem>?) {
if (categories == null) {
adapter!!.clear()
} else {
adapter!!.items = categories
}
adapter!!.notifyDataSetChanged()
if (binding == null) {
return
}
// Nested waiting for search result data to load into the category
// list and smoothly scroll to the top of the search result list.
binding!!.rvCategories.post {
binding!!.rvCategories.smoothScrollToPosition(0)
binding!!.rvCategories.post {
binding!!.rvCategories.smoothScrollToPosition(
0
)
}
}
}
override fun goToNextScreen() {
callback.onNextButtonClicked(callback.getIndexInViewFlipper(this))
}
override fun showNoCategorySelected() {
if (media == null) {
showAlertDialog(
requireActivity(),
getString(R.string.no_categories_selected),
getString(R.string.no_categories_selected_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel),
{ this.goToNextScreen() },
null
)
} else {
Toast.makeText(
requireContext(), getString(R.string.no_categories_selected),
Toast.LENGTH_SHORT
).show()
presenter!!.clearPreviousSelection()
goBackToPreviousScreen()
}
}
/**
* Gets existing categories from media
*/
override fun getExistingCategories(): List<String>? {
return media?.categories
}
/**
* Returns required context
*/
override fun getFragmentContext(): Context {
return requireContext()
}
/**
* Returns to previous fragment
*/
override fun goBackToPreviousScreen() {
fragmentManager?.popBackStack()
}
/**
* Shows the progress dialog
*/
override fun showProgressDialog() {
progressDialog = ProgressDialog(requireContext()).apply {
setMessage(getString(R.string.please_wait))
}.also {
it.show()
}
}
/**
* Hides the progress dialog
*/
override fun dismissProgressDialog() {
progressDialog?.dismiss()
}
/**
* Refreshes the categories
*/
override fun refreshCategories() {
(parentFragment as MediaDetailFragment?)?.updateCategories()
}
/**
*
*/
override fun navigateToLoginScreen() {
val username = sessionManager!!.userName
val logoutListener = CommonsApplication.BaseLogoutListener(
requireActivity(),
requireActivity().getString(R.string.invalid_login_message),
username
)
instance.clearApplicationData(
requireActivity(), logoutListener
)
}
fun onNextButtonClicked() {
if (media != null) {
presenter!!.updateCategories(media!!, wikiText!!)
} else {
presenter!!.verifyCategories()
}
}
fun onPreviousButtonClicked() {
if (media != null) {
presenter!!.clearPreviousSelection()
adapter!!.items = null
val mediaDetailFragment = checkNotNull(parentFragment as MediaDetailFragment?)
mediaDetailFragment.onResume()
goBackToPreviousScreen()
} else {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this))
}
}
override fun onBecameVisible() {
super.onBecameVisible()
if (binding == null) {
return
}
presenter!!.selectCategories()
val text = binding!!.etSearch.text
if (text != null) {
presenter!!.searchForCategories(text.toString())
}
}
/**
* Hides the action bar while opening editing fragment
*/
override fun onResume() {
super.onResume()
if (media != null) {
binding!!.etSearch.setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent? ->
if (keyCode == KeyEvent.KEYCODE_BACK) {
binding!!.etSearch.clearFocus()
presenter!!.clearPreviousSelection()
val mediaDetailFragment =
checkNotNull(parentFragment as MediaDetailFragment?)
mediaDetailFragment.onResume()
goBackToPreviousScreen()
return@setOnKeyListener true
}
false
}
requireView().isFocusableInTouchMode = true
requireView().requestFocus()
requireView().setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
presenter!!.clearPreviousSelection()
val mediaDetailFragment =
checkNotNull(parentFragment as MediaDetailFragment?)
mediaDetailFragment.onResume()
goBackToPreviousScreen()
return@setOnKeyListener true
}
false
}
(requireActivity() as AppCompatActivity).supportActionBar?.hide()
if (parentFragment?.parentFragment?.parentFragment is ContributionsFragment) {
((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility = View.GONE
}
}
}
/**
* Shows the action bar while closing editing fragment
*/
override fun onStop() {
super.onStop()
if (media != null) {
(requireActivity() as AppCompatActivity).supportActionBar?.show()
}
}
override fun onDestroy() {
super.onDestroy()
binding = null
}
}

View file

@ -1,444 +0,0 @@
package fr.free.nrw.commons.upload.depicts;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Context;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.jakewharton.rxbinding2.view.RxView;
import com.jakewharton.rxbinding2.widget.RxTextView;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionsFragment;
import fr.free.nrw.commons.databinding.UploadDepictsFragmentBinding;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.media.MediaDetailFragment;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadBaseFragment;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import fr.free.nrw.commons.utils.DialogUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.inject.Named;
import kotlin.Unit;
import timber.log.Timber;
/**
* Fragment for showing depicted items list in Upload activity after media details
*/
public class DepictsFragment extends UploadBaseFragment implements DepictsContract.View {
@Inject
@Named("default_preferences")
public
JsonKvStore applicationKvStore;
@Inject
DepictsContract.UserActionListener presenter;
private UploadDepictsAdapter adapter;
private Disposable subscribe;
private Media media;
private ProgressDialog progressDialog;
/**
* Determines each encounter of edit depicts
*/
private int count;
private Place nearbyPlace;
private UploadDepictsFragmentBinding binding;
@Inject
SessionManager sessionManager;
@Nullable
@Override
public android.view.View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = UploadDepictsFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull android.view.View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
Bundle bundle = getArguments();
if (bundle != null) {
media = bundle.getParcelable("Existing_Depicts");
nearbyPlace = bundle.getParcelable(SELECTED_NEARBY_PLACE);
}
if(callback!=null || media!=null){
init();
presenter.getDepictedItems().observe(getViewLifecycleOwner(), this::setDepictsList);
}
}
/**
* Initialize presenter and views
*/
private void init() {
if (binding == null) {
return;
}
if (media == null) {
binding.depictsTitle.setText(String.format(getString(R.string.step_count), callback.getIndexInViewFlipper(this) + 1,
callback.getTotalNumberOfSteps(), getString(R.string.depicts_step_title)));
} else {
binding.depictsTitle.setText(R.string.edit_depictions);
binding.depictsSubtitle.setVisibility(View.GONE);
binding.depictsNext.setText(R.string.menu_save_categories);
binding.depictsPrevious.setText(R.string.menu_cancel_upload);
}
setDepictsSubTitle();
binding.tooltip.setOnClickListener(v -> DialogUtil
.showAlertDialog(getActivity(), getString(R.string.depicts_step_title),
getString(R.string.depicts_tooltip), getString(android.R.string.ok), null));
if (media == null) {
presenter.onAttachView(this);
} else {
presenter.onAttachViewWithMedia(this, media);
}
initRecyclerView();
addTextChangeListenerToSearchBox();
binding.depictsNext.setOnClickListener(v->onNextButtonClicked());
binding.depictsPrevious.setOnClickListener(v->onPreviousButtonClicked());
}
/**
* Removes the depicts subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private void setDepictsSubTitle() {
final Activity activity = getActivity();
if (activity instanceof UploadActivity) {
final boolean isMultipleFileSelected = ((UploadActivity) activity).getIsMultipleFilesSelected();
if (!isMultipleFileSelected) {
binding.depictsSubtitle.setVisibility(View.GONE);
}
}
}
/**
* Initialise recyclerView and set adapter
*/
private void initRecyclerView() {
if (media == null) {
adapter = new UploadDepictsAdapter(categoryItem -> {
presenter.onDepictItemClicked(categoryItem);
return Unit.INSTANCE;
}, nearbyPlace);
} else {
adapter = new UploadDepictsAdapter(item -> {
presenter.onDepictItemClicked(item);
return Unit.INSTANCE;
}, nearbyPlace);
}
if (binding == null) {
return;
}
binding.depictsRecyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
binding.depictsRecyclerView.setAdapter(adapter);
}
@Override
protected void onBecameVisible() {
super.onBecameVisible();
// Select Place depiction as the fragment becomes visible to ensure that the most up to date
// Place is used (i.e. if the user accepts a nearby place dialog)
presenter.selectPlaceDepictions();
}
@Override
public void goToNextScreen() {
callback.onNextButtonClicked(callback.getIndexInViewFlipper(this));
}
@Override
public void goToPreviousScreen() {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this));
}
@Override
public void noDepictionSelected() {
if (media == null) {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.no_depictions_selected),
getString(R.string.no_depictions_selected_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel),
this::goToNextScreen,
null
);
} else {
Toast.makeText(requireContext(), getString(R.string.no_depictions_selected),
Toast.LENGTH_SHORT).show();
presenter.clearPreviousSelection();
updateDepicts();
goBackToPreviousScreen();
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
media = null;
presenter.onDetachView();
subscribe.dispose();
}
@Override
public void showProgress(boolean shouldShow) {
if (binding == null) {
return;
}
binding.depictsSearchInProgress.setVisibility(shouldShow ? View.VISIBLE : View.GONE);
}
@Override
public void showError(boolean value) {
if (binding == null) {
return;
}
if (value) {
binding.depictsSearchContainer.setError(getString(R.string.no_depiction_found));
} else {
binding.depictsSearchContainer.setErrorEnabled(false);
}
}
@Override
public void setDepictsList(List<DepictedItem> depictedItemList) {
if (applicationKvStore.getBoolean("first_edit_depict")) {
count = 1;
applicationKvStore.putBoolean("first_edit_depict", false);
adapter.setItems(depictedItemList);
} else {
if ((count == 0) && (!depictedItemList.isEmpty())) {
adapter.setItems(null);
count = 1;
} else {
adapter.setItems(depictedItemList);
}
}
if (binding == null) {
return;
}
// Nested waiting for search result data to load into the depicted item
// list and smoothly scroll to the top of the search result list.
binding.depictsRecyclerView.post(new Runnable() {
@Override
public void run() {
binding.depictsRecyclerView.smoothScrollToPosition(0);
binding.depictsRecyclerView.post(new Runnable() {
@Override
public void run() {
binding.depictsRecyclerView.smoothScrollToPosition(0);
}
});
}
});
}
/**
* Returns required context
*/
@Override
public Context getFragmentContext(){
return requireContext();
}
/**
* Returns to previous fragment
*/
@Override
public void goBackToPreviousScreen() {
getFragmentManager().popBackStack();
}
/**
* Gets existing depictions IDs from media
*/
@Override
public List<String> getExistingDepictions(){
return (media == null) ? null : media.getDepictionIds();
}
/**
* Shows the progress dialog
*/
@Override
public void showProgressDialog() {
progressDialog = new ProgressDialog(requireContext());
progressDialog.setMessage(getString(R.string.please_wait));
progressDialog.show();
}
/**
* Hides the progress dialog
*/
@Override
public void dismissProgressDialog() {
progressDialog.dismiss();
}
/**
* Update the depicts
*/
@Override
public void updateDepicts() {
final MediaDetailFragment mediaDetailFragment = (MediaDetailFragment) getParentFragment();
assert mediaDetailFragment != null;
mediaDetailFragment.onResume();
}
/**
* Navigates to the login Activity
*/
@Override
public void navigateToLoginScreen() {
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);
}
/**
* Determines the calling fragment by media nullability and act accordingly
*/
public void onNextButtonClicked() {
if(media != null){
presenter.updateDepictions(media);
} else {
presenter.verifyDepictions();
}
}
/**
* Determines the calling fragment by media nullability and act accordingly
*/
public void onPreviousButtonClicked() {
if(media != null){
presenter.clearPreviousSelection();
updateDepicts();
goBackToPreviousScreen();
} else {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this));
}
}
/**
* Text change listener for the edit text view of depicts
*/
private void addTextChangeListenerToSearchBox() {
subscribe = RxTextView.textChanges(binding.depictsSearch)
.doOnEach(v -> binding.depictsSearchContainer.setError(null))
.takeUntil(RxView.detaches(binding.depictsSearch))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(filter -> searchForDepictions(filter.toString()), Timber::e);
}
/**
* Search for depictions for the following query
*
* @param query query string
*/
private void searchForDepictions(final String query) {
presenter.searchForDepictions(query);
}
/**
* Hides the action bar while opening editing fragment
*/
@Override
public void onResume() {
super.onResume();
if (media != null) {
binding.depictsSearch.setOnKeyListener((v, keyCode, event) -> {
if (keyCode == KeyEvent.KEYCODE_BACK) {
binding.depictsSearch.clearFocus();
presenter.clearPreviousSelection();
updateDepicts();
goBackToPreviousScreen();
return true;
}
return false;
});
requireView().setFocusableInTouchMode(true);
getView().requestFocus();
getView().setOnKeyListener((v, keyCode, event) -> {
if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
presenter.clearPreviousSelection();
updateDepicts();
goBackToPreviousScreen();
return true;
}
return false;
});
Objects.requireNonNull(
((AppCompatActivity) requireActivity()).getSupportActionBar())
.hide();
if (getParentFragment().getParentFragment().getParentFragment()
instanceof ContributionsFragment) {
((ContributionsFragment) (getParentFragment()
.getParentFragment().getParentFragment())).binding.cardViewNearby
.setVisibility(View.GONE);
}
}
}
/**
* Shows the action bar while closing editing fragment
*/
@Override
public void onStop() {
super.onStop();
if (media != null) {
Objects.requireNonNull(
((AppCompatActivity) requireActivity()).getSupportActionBar())
.show();
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,424 @@
package fr.free.nrw.commons.upload.depicts
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.jakewharton.rxbinding2.view.RxView
import com.jakewharton.rxbinding2.widget.RxTextView
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.CommonsApplication.Companion.instance
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.ContributionsFragment
import fr.free.nrw.commons.databinding.UploadDepictsFragmentBinding
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.media.MediaDetailFragment
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadBaseFragment
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE
import io.reactivex.Notification
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Named
/**
* Fragment for showing depicted items list in Upload activity after media details
*/
class DepictsFragment : UploadBaseFragment(), DepictsContract.View {
@Inject
@field:Named("default_preferences")
lateinit var applicationKvStore: JsonKvStore
@Inject
lateinit var presenter: DepictsContract.UserActionListener
@Inject
lateinit var sessionManager: SessionManager
private var adapter: UploadDepictsAdapter? = null
private var subscribe: Disposable? = null
private var media: Media? = null
private var progressDialog: ProgressDialog? = null
/**
* Determines each encounter of edit depicts
*/
private var count = 0
private var nearbyPlace: Place? = null
private var _binding: UploadDepictsFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = UploadDepictsFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.let {
media = it.getParcelable("Existing_Depicts")
nearbyPlace = it.getParcelable(SELECTED_NEARBY_PLACE)
}
if (callback != null || media != null) {
init()
presenter.getDepictedItems().observe(viewLifecycleOwner, ::setDepictsList)
}
}
/**
* Initialize presenter and views
*/
private fun init() {
if (_binding == null) {
return
}
if (media == null) {
binding.depictsTitle.text =
String.format(
getString(R.string.step_count), callback.getIndexInViewFlipper(
this
) + 1,
callback.totalNumberOfSteps, getString(R.string.depicts_step_title)
)
} else {
binding.depictsTitle.setText(R.string.edit_depictions)
binding.depictsSubtitle.visibility = View.GONE
binding.depictsNext.setText(R.string.menu_save_categories)
binding.depictsPrevious.setText(R.string.menu_cancel_upload)
}
setDepictsSubTitle()
binding.tooltip.setOnClickListener { v: View? ->
showAlertDialog(
requireActivity(),
getString(R.string.depicts_step_title),
getString(R.string.depicts_tooltip),
getString(android.R.string.ok),
null
)
}
if (media == null) {
presenter.onAttachView(this)
} else {
presenter.onAttachViewWithMedia(this, media!!)
}
initRecyclerView()
addTextChangeListenerToSearchBox()
binding.depictsNext.setOnClickListener { v: View? -> onNextButtonClicked() }
binding.depictsPrevious.setOnClickListener { v: View? -> onPreviousButtonClicked() }
}
/**
* Removes the depicts subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private fun setDepictsSubTitle() {
val activity: Activity? = activity
if (activity is UploadActivity) {
val isMultipleFileSelected = activity.isMultipleFilesSelected
if (!isMultipleFileSelected) {
binding.depictsSubtitle.visibility = View.GONE
}
}
}
/**
* Initialise recyclerView and set adapter
*/
private fun initRecyclerView() {
adapter = if (media == null) {
UploadDepictsAdapter({ categoryItem: DepictedItem? ->
presenter.onDepictItemClicked(categoryItem!!)
}, nearbyPlace)
} else {
UploadDepictsAdapter({ item: DepictedItem? ->
presenter.onDepictItemClicked(item!!)
}, nearbyPlace)
}
if (_binding == null) {
return
}
binding.depictsRecyclerView.layoutManager = LinearLayoutManager(context)
binding.depictsRecyclerView.adapter = adapter
}
override fun onBecameVisible() {
super.onBecameVisible()
// Select Place depiction as the fragment becomes visible to ensure that the most up to date
// Place is used (i.e. if the user accepts a nearby place dialog)
presenter.selectPlaceDepictions()
}
override fun goToNextScreen() {
callback.onNextButtonClicked(callback.getIndexInViewFlipper(this))
}
override fun goToPreviousScreen() {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this))
}
override fun noDepictionSelected() {
if (media == null) {
showAlertDialog(
requireActivity(),
getString(R.string.no_depictions_selected),
getString(R.string.no_depictions_selected_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel),
{ goToNextScreen() },
null
)
} else {
Toast.makeText(
requireContext(), getString(R.string.no_depictions_selected),
Toast.LENGTH_SHORT
).show()
presenter.clearPreviousSelection()
updateDepicts()
goBackToPreviousScreen()
}
}
override fun onDestroyView() {
super.onDestroyView()
media = null
presenter.onDetachView()
subscribe!!.dispose()
}
override fun showProgress(shouldShow: Boolean) {
if (_binding == null) {
return
}
binding.depictsSearchInProgress.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
override fun showError(value: Boolean) {
if (_binding == null) {
return
}
if (value) {
binding.depictsSearchContainer.error =
getString(R.string.no_depiction_found)
} else {
binding.depictsSearchContainer.isErrorEnabled = false
}
}
override fun setDepictsList(depictedItemList: List<DepictedItem>) {
if (applicationKvStore.getBoolean("first_edit_depict")) {
count = 1
applicationKvStore.putBoolean("first_edit_depict", false)
adapter!!.items = depictedItemList
} else {
if ((count == 0) && (!depictedItemList.isEmpty())) {
adapter!!.items = null
count = 1
} else {
adapter!!.items = depictedItemList
}
}
if (_binding == null) {
return
}
// Nested waiting for search result data to load into the depicted item
// list and smoothly scroll to the top of the search result list.
binding.depictsRecyclerView.post {
binding.depictsRecyclerView.smoothScrollToPosition(0)
binding.depictsRecyclerView.post {
binding.depictsRecyclerView.smoothScrollToPosition(
0
)
}
}
}
/**
* Returns required context
*/
override fun getFragmentContext(): Context {
return requireContext()
}
/**
* Returns to previous fragment
*/
override fun goBackToPreviousScreen() {
fragmentManager?.popBackStack()
}
/**
* Gets existing depictions IDs from media
*/
override fun getExistingDepictions(): List<String>? {
return if ((media == null)) null else media!!.depictionIds
}
/**
* Shows the progress dialog
*/
override fun showProgressDialog() {
progressDialog = ProgressDialog(requireContext())
progressDialog!!.setMessage(getString(R.string.please_wait))
progressDialog!!.show()
}
/**
* Hides the progress dialog
*/
override fun dismissProgressDialog() {
progressDialog?.dismiss()
}
/**
* Update the depicts
*/
override fun updateDepicts() {
(parentFragment as MediaDetailFragment?)?.onResume()
}
/**
* Navigates to the login Activity
*/
override fun navigateToLoginScreen() {
val username = sessionManager.userName
val logoutListener = CommonsApplication.BaseLogoutListener(
requireActivity(),
requireActivity().getString(R.string.invalid_login_message),
username
)
instance.clearApplicationData(
requireActivity(), logoutListener
)
}
/**
* Determines the calling fragment by media nullability and act accordingly
*/
fun onNextButtonClicked() {
if (media != null) {
presenter.updateDepictions(media!!)
} else {
presenter.verifyDepictions()
}
}
/**
* Determines the calling fragment by media nullability and act accordingly
*/
fun onPreviousButtonClicked() {
if (media != null) {
presenter.clearPreviousSelection()
updateDepicts()
goBackToPreviousScreen()
} else {
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this))
}
}
/**
* Text change listener for the edit text view of depicts
*/
private fun addTextChangeListenerToSearchBox() {
subscribe = RxTextView.textChanges(binding.depictsSearch)
.doOnEach { v: Notification<CharSequence?>? ->
binding.depictsSearchContainer.error =
null
}
.takeUntil(RxView.detaches(binding.depictsSearch))
.debounce(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ filter: CharSequence -> searchForDepictions(filter.toString()) },
{ t: Throwable? -> Timber.e(t) })
}
/**
* Search for depictions for the following query
*
* @param query query string
*/
private fun searchForDepictions(query: String) {
presenter.searchForDepictions(query)
}
/**
* Hides the action bar while opening editing fragment
*/
override fun onResume() {
super.onResume()
if (media != null) {
binding.depictsSearch.setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent? ->
if (keyCode == KeyEvent.KEYCODE_BACK) {
binding.depictsSearch.clearFocus()
presenter.clearPreviousSelection()
updateDepicts()
goBackToPreviousScreen()
return@setOnKeyListener true
}
false
}
requireView().isFocusableInTouchMode = true
requireView().requestFocus()
requireView().setOnKeyListener { v: View?, keyCode: Int, event: KeyEvent ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
presenter.clearPreviousSelection()
updateDepicts()
goBackToPreviousScreen()
return@setOnKeyListener true
}
false
}
(requireActivity() as AppCompatActivity).supportActionBar?.hide()
if (parentFragment?.parentFragment?.parentFragment is ContributionsFragment) {
((parentFragment?.parentFragment?.parentFragment) as ContributionsFragment?)?.binding?.cardViewNearby?.setVisibility(View.GONE)
}
}
}
/**
* Shows the action bar while closing editing fragment
*/
override fun onStop() {
super.onStop()
if (media != null) {
(requireActivity() as AppCompatActivity).supportActionBar?.show()
}
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}

View file

@ -11,13 +11,13 @@ interface MediaLicenseContract {
fun setSelectedLicense(license: String?)
fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int?)
fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int)
}
interface UserActionListener : BasePresenter<View> {
fun getLicenses()
fun selectLicense(licenseName: String)
fun selectLicense(licenseName: String?)
fun isWLMSupportedForThisPlace(): Boolean
}

View file

@ -1,205 +0,0 @@
package fr.free.nrw.commons.upload.license;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.text.Html;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.databinding.FragmentMediaLicenseBinding;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import java.util.List;
import javax.inject.Inject;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.upload.UploadBaseFragment;
import timber.log.Timber;
public class MediaLicenseFragment extends UploadBaseFragment implements MediaLicenseContract.View {
@Inject
MediaLicenseContract.UserActionListener presenter;
private FragmentMediaLicenseBinding binding;
private ArrayAdapter<String> adapter;
private List<String> licenses;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentMediaLicenseBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
binding.tvTitle.setText(getString(R.string.step_count,
callback.getIndexInViewFlipper(this) + 1,
callback.getTotalNumberOfSteps(),
getString(R.string.license_step_title))
);
setTvSubTitle();
binding.btnPrevious.setOnClickListener(v ->
callback.onPreviousButtonClicked(callback.getIndexInViewFlipper(this))
);
binding.btnSubmit.setOnClickListener(v ->
callback.onNextButtonClicked(callback.getIndexInViewFlipper(this))
);
binding.tooltip.setOnClickListener(v ->
DialogUtil.showAlertDialog(requireActivity(),
getString(R.string.license_step_title),
getString(R.string.license_tooltip),
getString(android.R.string.ok),
null)
);
initPresenter();
initLicenseSpinner();
presenter.getLicenses();
}
/**
* Removes the tv Subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private void setTvSubTitle() {
final Activity activity = getActivity();
if (activity instanceof UploadActivity) {
final boolean isMultipleFileSelected = ((UploadActivity) activity).getIsMultipleFilesSelected();
if (!isMultipleFileSelected) {
binding.tvSubtitle.setVisibility(View.GONE);
}
}
}
private void initPresenter() {
presenter.onAttachView(this);
}
/**
* Initialise the license spinner
*/
private void initLicenseSpinner() {
if (getActivity() == null) {
return;
}
adapter = new ArrayAdapter<>(getActivity().getApplicationContext(), android.R.layout.simple_spinner_dropdown_item);
binding.spinnerLicenseList.setAdapter(adapter);
binding.spinnerLicenseList.setOnItemSelectedListener(new OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int position,
long l) {
String licenseName = adapterView.getItemAtPosition(position).toString();
presenter.selectLicense(licenseName);
}
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
presenter.selectLicense(null);
}
});
}
@Override
public void setLicenses(List<String> licenses) {
adapter.clear();
this.licenses = licenses;
adapter.addAll(this.licenses);
adapter.notifyDataSetChanged();
}
@Override
public void setSelectedLicense(String license) {
int position = licenses.indexOf(getString(Utils.licenseNameFor(license)));
// Check if position is valid
if (position < 0) {
Timber.d("Invalid position: %d. Using default licenses", position);
position = licenses.size() - 1;
} else {
Timber.d("Position: %d %s", position, getString(Utils.licenseNameFor(license)));
}
binding.spinnerLicenseList.setSelection(position);
}
@Override
public void updateLicenseSummary(String licenseSummary, Integer numberOfItems) {
String licenseHyperLink = "<a href='" + Utils.licenseUrlFor(licenseSummary) + "'>" +
getString(Utils.licenseNameFor(licenseSummary)) + "</a><br>";
setTextViewHTML(binding.tvShareLicenseSummary, getResources()
.getQuantityString(R.plurals.share_license_summary, numberOfItems,
licenseHyperLink));
}
private void setTextViewHTML(TextView textView, String text) {
CharSequence sequence = Html.fromHtml(text);
SpannableStringBuilder strBuilder = new SpannableStringBuilder(sequence);
URLSpan[] urls = strBuilder.getSpans(0, sequence.length(), URLSpan.class);
for (URLSpan span : urls) {
makeLinkClickable(strBuilder, span);
}
textView.setText(strBuilder);
textView.setMovementMethod(LinkMovementMethod.getInstance());
}
private void makeLinkClickable(SpannableStringBuilder strBuilder, final URLSpan span) {
int start = strBuilder.getSpanStart(span);
int end = strBuilder.getSpanEnd(span);
int flags = strBuilder.getSpanFlags(span);
ClickableSpan clickable = new ClickableSpan() {
@Override
public void onClick(View view) {
// Handle hyperlink click
String hyperLink = span.getURL();
launchBrowser(hyperLink);
}
};
strBuilder.setSpan(clickable, start, end, flags);
strBuilder.removeSpan(span);
}
private void launchBrowser(String hyperLink) {
Utils.handleWebUrl(getContext(), Uri.parse(hyperLink));
}
@Override
public void onDestroyView() {
presenter.onDetachView();
//Free the adapter to avoid memory leaks
adapter = null;
binding = null;
super.onDestroyView();
}
@Override
protected void onBecameVisible() {
super.onBecameVisible();
/**
* Show the wlm info message if the upload is a WLM upload
*/
if(callback.isWLMUpload() && presenter.isWLMSupportedForThisPlace()){
binding.llInfoMonumentUpload.setVisibility(View.VISIBLE);
}else{
binding.llInfoMonumentUpload.setVisibility(View.GONE);
}
}
}

View file

@ -0,0 +1,201 @@
package fr.free.nrw.commons.upload.license
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.TextView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.databinding.FragmentMediaLicenseBinding
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadBaseFragment
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import timber.log.Timber
import javax.inject.Inject
class MediaLicenseFragment : UploadBaseFragment(), MediaLicenseContract.View {
@Inject
lateinit var presenter: MediaLicenseContract.UserActionListener
private var _binding: FragmentMediaLicenseBinding? = null
private val binding: FragmentMediaLicenseBinding get() = _binding!!
private var adapter: ArrayAdapter<String>? = null
private var licenses: List<String>? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentMediaLicenseBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvTitle.text = getString(
R.string.step_count,
callback.getIndexInViewFlipper(this) + 1,
callback.totalNumberOfSteps,
getString(R.string.license_step_title)
)
setTvSubTitle()
binding.btnPrevious.setOnClickListener {
callback.onPreviousButtonClicked(
callback.getIndexInViewFlipper(this)
)
}
binding.btnSubmit.setOnClickListener {
callback.onNextButtonClicked(
callback.getIndexInViewFlipper(this)
)
}
binding.tooltip.setOnClickListener {
showAlertDialog(
requireActivity(),
getString(R.string.license_step_title),
getString(R.string.license_tooltip),
getString(android.R.string.ok),
null
)
}
initPresenter()
initLicenseSpinner()
presenter.getLicenses()
}
/**
* Removes the tv Subtitle If the activity is the instance of [UploadActivity] and
* if multiple files aren't selected.
*/
private fun setTvSubTitle() {
val activity: Activity? = activity
if (activity is UploadActivity) {
if (!activity.isMultipleFilesSelected) {
binding.tvSubtitle.visibility = View.GONE
}
}
}
private fun initPresenter() = presenter.onAttachView(this)
/**
* Initialise the license spinner
*/
private fun initLicenseSpinner() {
if (activity == null) {
return
}
adapter = ArrayAdapter(
requireActivity().applicationContext,
android.R.layout.simple_spinner_dropdown_item
)
binding.spinnerLicenseList.adapter = adapter
binding.spinnerLicenseList.onItemSelectedListener =
object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(adapterView: AdapterView<*>, view: View, position: Int, l: Long) {
val licenseName = adapterView.getItemAtPosition(position).toString()
presenter.selectLicense(licenseName)
}
override fun onNothingSelected(adapterView: AdapterView<*>?) {
presenter.selectLicense(null)
}
}
}
override fun setLicenses(licenses: List<String>?) {
adapter!!.clear()
this.licenses = licenses
adapter!!.addAll(this.licenses!!)
adapter!!.notifyDataSetChanged()
}
override fun setSelectedLicense(license: String?) {
var position = licenses!!.indexOf(getString(Utils.licenseNameFor(license)))
// Check if position is valid
if (position < 0) {
Timber.d("Invalid position: %d. Using default licenses", position)
position = licenses!!.size - 1
} else {
Timber.d("Position: %d %s", position, getString(Utils.licenseNameFor(license)))
}
binding.spinnerLicenseList.setSelection(position)
}
override fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int) {
val licenseHyperLink = "<a href='" + Utils.licenseUrlFor(selectedLicense) + "'>" +
getString(Utils.licenseNameFor(selectedLicense)) + "</a><br>"
setTextViewHTML(
binding.tvShareLicenseSummary, resources
.getQuantityString(
R.plurals.share_license_summary, numberOfItems,
licenseHyperLink
)
)
}
private fun setTextViewHTML(textView: TextView, text: String) {
val sequence: CharSequence = Html.fromHtml(text)
val strBuilder = SpannableStringBuilder(sequence)
val urls = strBuilder.getSpans(
0, sequence.length,
URLSpan::class.java
)
for (span in urls) {
makeLinkClickable(strBuilder, span)
}
textView.text = strBuilder
textView.movementMethod = LinkMovementMethod.getInstance()
}
private fun makeLinkClickable(strBuilder: SpannableStringBuilder, span: URLSpan) {
val start = strBuilder.getSpanStart(span)
val end = strBuilder.getSpanEnd(span)
val flags = strBuilder.getSpanFlags(span)
val clickable: ClickableSpan = object : ClickableSpan() {
override fun onClick(view: View) {
// Handle hyperlink click
val hyperLink = span.url
launchBrowser(hyperLink)
}
}
strBuilder.setSpan(clickable, start, end, flags)
strBuilder.removeSpan(span)
}
private fun launchBrowser(hyperLink: String) =
Utils.handleWebUrl(context, Uri.parse(hyperLink))
override fun onDestroyView() {
presenter.onDetachView()
//Free the adapter to avoid memory leaks
adapter = null
_binding = null
super.onDestroyView()
}
override fun onBecameVisible() {
super.onBecameVisible()
/**
* Show the wlm info message if the upload is a WLM upload
*/
binding.llInfoMonumentUpload.visibility =
if (callback.isWLMUpload && presenter.isWLMSupportedForThisPlace()) View.VISIBLE else View.GONE
}
}

View file

@ -1,83 +0,0 @@
package fr.free.nrw.commons.upload.license;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.license.MediaLicenseContract.View;
import java.lang.reflect.Proxy;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* Added JavaDocs for MediaLicensePresenter
*/
public class MediaLicensePresenter implements MediaLicenseContract.UserActionListener {
private static final MediaLicenseContract.View DUMMY = (MediaLicenseContract.View) Proxy
.newProxyInstance(
MediaLicenseContract.View.class.getClassLoader(),
new Class[]{MediaLicenseContract.View.class},
(proxy, method, methodArgs) -> null);
private final UploadRepository repository;
private final JsonKvStore defaultKVStore;
private MediaLicenseContract.View view = DUMMY;
@Inject
public MediaLicensePresenter(final UploadRepository uploadRepository,
@Named("default_preferences") final JsonKvStore defaultKVStore) {
this.repository = uploadRepository;
this.defaultKVStore = defaultKVStore;
}
@Override
public void onAttachView(@NonNull final View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
}
/**
* asks the repository for the available licenses, and informs the view on the same
*/
@Override
public void getLicenses() {
final List<String> licenses = repository.getLicenses();
view.setLicenses(licenses);
String selectedLicense = defaultKVStore.getString(Prefs.DEFAULT_LICENSE,
Prefs.Licenses.CC_BY_SA_4);//CC_BY_SA_4 is the default one used by the commons web app
try {//I have to make sure that the stored default license was not one of the deprecated one's
Utils.licenseNameFor(selectedLicense);
} catch (final IllegalStateException exception) {
Timber.e(exception);
selectedLicense = Prefs.Licenses.CC_BY_SA_4;
defaultKVStore.putString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_4);
}
view.setSelectedLicense(selectedLicense);
}
/**
* ask the repository to select a license for the current upload
*
* @param licenseName
*/
@Override
public void selectLicense(final String licenseName) {
repository.setSelectedLicense(licenseName);
view.updateLicenseSummary(repository.getSelectedLicense(), repository.getCount());
}
@Override
public boolean isWLMSupportedForThisPlace() {
return repository.isWMLSupportedForThisPlace();
}
}

View file

@ -0,0 +1,68 @@
package fr.free.nrw.commons.upload.license
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.settings.Prefs
import timber.log.Timber
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import javax.inject.Inject
import javax.inject.Named
/**
* Added JavaDocs for MediaLicensePresenter
*/
class MediaLicensePresenter @Inject constructor(
private val repository: UploadRepository,
@param:Named("default_preferences") private val defaultKVStore: JsonKvStore
) : MediaLicenseContract.UserActionListener {
private var view = DUMMY
override fun onAttachView(view: MediaLicenseContract.View) {
this.view = view
}
override fun onDetachView() {
view = DUMMY
}
/**
* asks the repository for the available licenses, and informs the view on the same
*/
override fun getLicenses() {
val licenses = repository.getLicenses()
view.setLicenses(licenses)
var selectedLicense = defaultKVStore.getString(
Prefs.DEFAULT_LICENSE,
Prefs.Licenses.CC_BY_SA_4
) //CC_BY_SA_4 is the default one used by the commons web app
try { //I have to make sure that the stored default license was not one of the deprecated one's
Utils.licenseNameFor(selectedLicense)
} catch (exception: IllegalStateException) {
Timber.e(exception)
selectedLicense = Prefs.Licenses.CC_BY_SA_4
defaultKVStore.putString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_4)
}
view.setSelectedLicense(selectedLicense)
}
/**
* ask the repository to select a license for the current upload
*/
override fun selectLicense(licenseName: String?) {
repository.setSelectedLicense(licenseName)
view.updateLicenseSummary(repository.getSelectedLicense(), repository.getCount())
}
override fun isWLMSupportedForThisPlace(): Boolean =
repository.isWMLSupportedForThisPlace()
companion object {
private val DUMMY = Proxy.newProxyInstance(
MediaLicenseContract.View::class.java.classLoader,
arrayOf<Class<*>>(MediaLicenseContract.View::class.java)
) { _: Any?, _: Method?, _: Array<Any?>? -> null } as MediaLicenseContract.View
}
}

View file

@ -521,7 +521,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
getString(R.string.duplicate_file_name),
String.format(Locale.getDefault(),
uploadTitleFormat,
uploadItem.getFileName()),
uploadItem.getFilename()),
getString(R.string.upload),
getString(R.string.cancel),
() -> {
@ -714,7 +714,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
if (binding != null){
binding.backgroundImage.setImageURI(Uri.fromFile(new File(path)));
}
editableUploadItem.setContentUri(Uri.fromFile(new File(path)));
editableUploadItem.setContentAndMediaUri(Uri.fromFile(new File(path)));
callback.changeThumbnail(indexOfFragment,
path);
} catch (Exception e) {

View file

@ -106,7 +106,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt
*/
@Override
public void setUploadMediaDetails(final List<UploadMediaDetail> uploadMediaDetails, final int uploadItemIndex) {
repository.getUploads().get(uploadItemIndex).setMediaDetails(uploadMediaDetails);
repository.getUploads().get(uploadItemIndex).setUploadMediaDetails(uploadMediaDetails);
}
/**
@ -284,7 +284,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt
public void copyTitleAndDescriptionToSubsequentMedia(final int indexInViewFlipper) {
for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){
final UploadItem subsequentUploadItem = repository.getUploads().get(i);
subsequentUploadItem.setMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails()));
subsequentUploadItem.setUploadMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails()));
}
}

View file

@ -40,11 +40,11 @@ class DepictModel
place.wikiDataEntityId?.let { qids.add(it) }
}
repository.getUploads().forEach { item ->
if (item.gpsCoords != null && item.gpsCoords.imageCoordsExists) {
if (item.gpsCoords != null && item.gpsCoords?.imageCoordsExists == true) {
Coordinates2Country
.countryQID(
item.gpsCoords.decLatitude,
item.gpsCoords.decLongitude,
item.gpsCoords!!.decLatitude,
item.gpsCoords!!.decLongitude,
)?.let { qids.add("Q$it") }
}
}

View file

@ -84,10 +84,10 @@ class CategoriesPresenterTest {
)
val nonEmptyCaptionUploadItem = mock<UploadItem>()
whenever(nonEmptyCaptionUploadItem.uploadMediaDetails)
.thenReturn(listOf(UploadMediaDetail(captionText = "nonEmpty")))
.thenReturn(mutableListOf(UploadMediaDetail(captionText = "nonEmpty")))
val emptyCaptionUploadItem = mock<UploadItem>()
whenever(emptyCaptionUploadItem.uploadMediaDetails)
.thenReturn(listOf(UploadMediaDetail(captionText = "")))
.thenReturn(mutableListOf(UploadMediaDetail(captionText = "")))
whenever(repository.getUploads()).thenReturn(
listOf(
nonEmptyCaptionUploadItem,

View file

@ -64,7 +64,7 @@ class ImageProcessingServiceTest {
`when`(uploadItem.uploadMediaDetails).thenReturn(mockTitle as MutableList<UploadMediaDetail>?)
`when`(uploadItem.place).thenReturn(mockPlace)
`when`(uploadItem.fileName).thenReturn("File:jpg")
`when`(uploadItem.filename).thenReturn("File:jpg")
`when`(fileUtilsWrapper!!.getFileInputStream(ArgumentMatchers.anyString()))
.thenReturn(mock(FileInputStream::class.java))

View file

@ -137,7 +137,7 @@ class UploadMediaPresenterTest {
whenever(uploadItem.imageQuality).thenReturn(0)
whenever(uploadItem.gpsCoords)
.thenReturn(imageCoordinates)
whenever(uploadItem.gpsCoords.decimalCoords)
whenever(uploadItem.gpsCoords?.decimalCoords)
.thenReturn("imageCoordinates")
uploadMediaPresenter.getImageQuality(0, location, mockActivity)
verify(view).showProgress(true)
@ -155,7 +155,7 @@ class UploadMediaPresenterTest {
whenever(uploadItem.imageQuality).thenReturn(0)
whenever(uploadItem.gpsCoords)
.thenReturn(imageCoordinates)
whenever(uploadItem.gpsCoords.decimalCoords)
whenever(uploadItem.gpsCoords?.decimalCoords)
.thenReturn(null)
uploadMediaPresenter.getImageQuality(0, location, mockActivity)
testScheduler.triggerActions()
@ -195,7 +195,7 @@ class UploadMediaPresenterTest {
uploadMediaDetail.languageCode = "en"
val uploadMediaDetailList: ArrayList<UploadMediaDetail> = ArrayList()
uploadMediaDetailList.add(uploadMediaDetail)
uploadItem.setMediaDetails(uploadMediaDetailList)
uploadItem.uploadMediaDetails = uploadMediaDetailList
Mockito.`when`(repository.getImageQuality(uploadItem, location)).then {
verify(view).showProgress(true)
testScheduler.triggerActions()
@ -211,7 +211,7 @@ class UploadMediaPresenterTest {
uploadMediaDetail.languageCode = "en"
uploadMediaDetail.captionText = "added caption"
uploadMediaDetail.languageCode = "eo"
uploadItem.setMediaDetails(Collections.singletonList(uploadMediaDetail))
uploadItem.uploadMediaDetails = Collections.singletonList(uploadMediaDetail)
Mockito.`when`(repository.getImageQuality(uploadItem, location)).then {
verify(view).showProgress(true)
testScheduler.triggerActions()
@ -228,7 +228,7 @@ class UploadMediaPresenterTest {
whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
whenever(repository.getUploadItem(ArgumentMatchers.anyInt()))
.thenReturn(uploadItem)
whenever(uploadItem.uploadMediaDetails).thenReturn(listOf())
whenever(uploadItem.uploadMediaDetails).thenReturn(mutableListOf())
uploadMediaPresenter.fetchTitleAndDescription(0)
verify(view).updateMediaDetails(ArgumentMatchers.any())

View file

@ -83,7 +83,7 @@ class UploadPresenterTest {
@Test
fun handleSubmitImagesNoLocationWithConsecutiveNoLocationUploads() {
`when`(imageCoords.imageCoordsExists).thenReturn(false)
`when`(uploadItem.getGpsCoords()).thenReturn(imageCoords)
`when`(uploadItem.gpsCoords).thenReturn(imageCoords)
`when`(repository.getUploads()).thenReturn(uploadableItems)
uploadableItems.add(uploadItem)
@ -111,7 +111,7 @@ class UploadPresenterTest {
defaultKvStore.getInt(UploadPresenter.COUNTER_OF_CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES, 0),
).thenReturn(UploadPresenter.CONSECUTIVE_UPLOADS_WITHOUT_COORDINATES_REMINDER_THRESHOLD)
`when`(imageCoords.imageCoordsExists).thenReturn(true)
`when`(uploadItem.getGpsCoords()).thenReturn(imageCoords)
`when`(uploadItem.gpsCoords).thenReturn(imageCoords)
`when`(repository.getUploads()).thenReturn(uploadableItems)
uploadableItems.add(uploadItem)
uploadPresenter.handleSubmit()

View file

@ -62,6 +62,7 @@ class UploadCategoriesFragmentUnitTests {
OkHttpConnectionFactory.CLIENT = createTestClient()
val activity = Robolectric.buildActivity(UploadActivity::class.java).create().get()
fragment = UploadCategoriesFragment()
fragment.callback = callback
fragmentManager = activity.supportFragmentManager
val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()
fragmentTransaction.add(fragment, null)

View file

@ -481,7 +481,7 @@ class UploadMediaDetailFragmentUnitTest {
`when`(imageCoordinates.zoomLevel).thenReturn(14.0)
`when`(defaultKvStore.getString(LAST_ZOOM)).thenReturn(null)
fragment.showExternalMap(uploadItem)
Mockito.verify(uploadItem.gpsCoords, Mockito.times(1)).zoomLevel
Mockito.verify(uploadItem.gpsCoords, Mockito.times(1))?.zoomLevel
val shadowActivity: ShadowActivity = shadowOf(activity)
val startedIntent = shadowActivity.nextStartedActivity
val shadowIntent: ShadowIntent = shadowOf(startedIntent)