Merge branch 'main' into 1-issue#5829

This commit is contained in:
Nicolas Raoul 2025-01-08 22:30:35 +09:00 committed by GitHub
commit 02a2bdc41b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 4292 additions and 3488 deletions

View file

@ -146,7 +146,7 @@ class LoginActivity : AccountAuthenticatorActivity() {
loginTwoFactor.removeTextChangedListener(textWatcher) loginTwoFactor.removeTextChangedListener(textWatcher)
} }
delegate.onDestroy() delegate.onDestroy()
loginClient?.cancel() loginClient.cancel()
binding = null binding = null
super.onDestroy() super.onDestroy()
} }
@ -182,34 +182,34 @@ class LoginActivity : AccountAuthenticatorActivity() {
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
// if progressDialog is visible during the configuration change then store state as true else false so that // if progressDialog is visible during the configuration change then store state as true else false so that
// we maintain visibility of progressDailog after configuration change // we maintain visibility of progressDialog after configuration change
if (progressDialog != null && progressDialog!!.isShowing) { if (progressDialog != null && progressDialog!!.isShowing) {
outState.putBoolean(saveProgressDailog, true) outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
} else { } else {
outState.putBoolean(saveProgressDailog, false) outState.putBoolean(SAVE_PROGRESS_DIALOG, false)
} }
outState.putString( outState.putString(
saveErrorMessage, SAVE_ERROR_MESSAGE,
binding!!.errorMessage.text.toString() binding!!.errorMessage.text.toString()
) //Save the errorMessage ) //Save the errorMessage
outState.putString( outState.putString(
saveUsername, SAVE_USERNAME,
binding!!.loginUsername.text.toString() binding!!.loginUsername.text.toString()
) // Save the username ) // Save the username
outState.putString( outState.putString(
savePassword, SAVE_PASSWORD,
binding!!.loginPassword.text.toString() binding!!.loginPassword.text.toString()
) // Save the password ) // Save the password
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
binding!!.loginUsername.setText(savedInstanceState.getString(saveUsername)) binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME))
binding!!.loginPassword.setText(savedInstanceState.getString(savePassword)) binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD))
if (savedInstanceState.getBoolean(saveProgressDailog)) { if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) {
performLogin() performLogin()
} }
val errorMessage = savedInstanceState.getString(saveErrorMessage) val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE)
if (sessionManager.isUserLoggedIn) { if (sessionManager.isUserLoggedIn) {
showMessage(R.string.login_success, R.color.primaryDarkColor) showMessage(R.string.login_success, R.color.primaryDarkColor)
} else { } else {
@ -396,9 +396,9 @@ class LoginActivity : AccountAuthenticatorActivity() {
fun startYourself(context: Context) = fun startYourself(context: Context) =
context.startActivity(Intent(context, LoginActivity::class.java)) context.startActivity(Intent(context, LoginActivity::class.java))
const val saveProgressDailog: String = "ProgressDailog_state" const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state"
const val saveErrorMessage: String = "errorMessage" const val SAVE_ERROR_MESSAGE: String = "errorMessage"
const val saveUsername: String = "username" const val SAVE_USERNAME: String = "username"
const val savePassword: String = "password" const val SAVE_PASSWORD: String = "password"
} }
} }

View file

@ -2,18 +2,17 @@ package fr.free.nrw.commons.bookmarks;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AdapterView; import android.widget.AdapterView;
import android.widget.FrameLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import fr.free.nrw.commons.Media; import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment;
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment; import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment; import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment; import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment;
@ -26,6 +25,7 @@ import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.navtab.NavTab; import fr.free.nrw.commons.navtab.NavTab;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import timber.log.Timber;
public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements
FragmentManager.OnBackStackChangedListener, FragmentManager.OnBackStackChangedListener,
@ -48,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
String title = bundle.getString("categoryName"); String title = bundle.getString("categoryName");
int order = bundle.getInt("order"); int order = bundle.getInt("order");
final int orderItem = bundle.getInt("orderItem"); final int orderItem = bundle.getInt("orderItem");
if (order == 0) {
listFragment = new BookmarkPicturesFragment(); switch (order){
} else { case 0: listFragment = new BookmarkPicturesFragment();
listFragment = new BookmarkLocationsFragment(); break;
case 1: listFragment = new BookmarkLocationsFragment();
break;
case 3: listFragment = new BookmarkCategoriesFragment();
break;
}
if(orderItem == 2) { if(orderItem == 2) {
listFragment = new BookmarkItemsFragment(); listFragment = new BookmarkItemsFragment();
} }
}
Bundle featuredArguments = new Bundle(); Bundle featuredArguments = new Bundle();
featuredArguments.putString("categoryName", title); featuredArguments.putString("categoryName", title);
listFragment.setArguments(featuredArguments); listFragment.setArguments(featuredArguments);
@ -129,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
@Override @Override
public void onMediaClicked(int position) { public void onMediaClicked(int position) {
Log.d("deneme8", "on media clicked"); Timber.d("on media clicked");
/*container.setVisibility(View.VISIBLE); /*container.setVisibility(View.VISIBLE);
((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE);
mediaDetails = new MediaDetailPagerFragment(false, true, position); mediaDetails = new MediaDetailPagerFragment(false, true, position);
@ -237,7 +244,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple
@Override @Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) { public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Log.d("deneme8", "on media clicked"); Timber.d("on media clicked");
binding.exploreContainer.setVisibility(View.VISIBLE); binding.exploreContainer.setVisibility(View.VISIBLE);
((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE); ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE);
mediaDetails = MediaDetailPagerFragment.newInstance(false, true); mediaDetails = MediaDetailPagerFragment.newInstance(false, true);

View file

@ -49,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter {
new BookmarkListRootFragment(locationBundle, this), new BookmarkListRootFragment(locationBundle, this),
context.getString(R.string.title_page_bookmarks_items))); context.getString(R.string.title_page_bookmarks_items)));
} }
final Bundle categoriesBundle = new Bundle();
categoriesBundle.putString("categoryName",
context.getString(R.string.title_page_bookmarks_categories));
categoriesBundle.putInt("order", 3);
pages.add(new BookmarkPages(
new BookmarkListRootFragment(categoriesBundle, this),
context.getString(R.string.title_page_bookmarks_categories)));
notifyDataSetChanged(); notifyDataSetChanged();
} }

View file

@ -0,0 +1,52 @@
package fr.free.nrw.commons.bookmarks.category
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
/**
* Bookmark categories dao
*
* @constructor Create empty Bookmark categories dao
*/
@Dao
interface BookmarkCategoriesDao {
/**
* Insert or Delete category bookmark into DB
*
* @param bookmarksCategoryModal
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(bookmarksCategoryModal: BookmarksCategoryModal)
/**
* Delete category bookmark from DB
*
* @param bookmarksCategoryModal
*/
@Delete
suspend fun delete(bookmarksCategoryModal: BookmarksCategoryModal)
/**
* Checks if given category exist in DB
*
* @param categoryName
* @return
*/
@Query("SELECT EXISTS (SELECT 1 FROM bookmarks_categories WHERE categoryName = :categoryName)")
suspend fun doesExist(categoryName: String): Boolean
/**
* Get all categories
*
* @return
*/
@Query("SELECT * FROM bookmarks_categories")
fun getAllCategories(): Flow<List<BookmarksCategoryModal>>
}

View file

@ -0,0 +1,143 @@
package fr.free.nrw.commons.bookmarks.category
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.android.support.DaggerFragment
import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryDetailsActivity
import javax.inject.Inject
/**
* Tab fragment to show list of bookmarked Categories
*/
class BookmarkCategoriesFragment : DaggerFragment() {
@Inject
lateinit var bookmarkCategoriesDao: BookmarkCategoriesDao
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme(
primary = colorResource(R.color.primaryDarkColor),
surface = colorResource(R.color.main_background_dark),
background = colorResource(R.color.main_background_dark)
) else lightColorScheme(
primary = colorResource(R.color.primaryColor),
surface = colorResource(R.color.main_background_light),
background = colorResource(R.color.main_background_light)
)
) {
val listOfBookmarks by bookmarkCategoriesDao.getAllCategories()
.collectAsStateWithLifecycle(initialValue = emptyList())
Surface(modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center) {
if (listOfBookmarks.isEmpty()) {
Text(
text = stringResource(R.string.bookmark_empty),
style = MaterialTheme.typography.bodyMedium,
color = if (isSystemInDarkTheme()) Color(0xB3FFFFFF)
else Color(
0x8A000000
)
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = listOfBookmarks) { bookmarkItem ->
CategoryItem(
categoryName = bookmarkItem.categoryName,
onClick = {
val categoryDetailsIntent = Intent(
requireContext(),
CategoryDetailsActivity::class.java
).putExtra("categoryName", it)
startActivity(categoryDetailsIntent)
}
)
}
}
}
}
}
}
}
}
}
@Composable
fun CategoryItem(
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
categoryName: String
) {
Row(modifier = modifier.clickable {
onClick(categoryName)
}) {
ListItem(
leadingContent = {
Image(
modifier = Modifier.size(48.dp),
painter = painterResource(R.drawable.commons),
contentDescription = null
)
},
headlineContent = {
Text(
text = categoryName,
maxLines = 2,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
)
}
}
@Preview
@Composable
private fun CategoryItemPreview() {
CategoryItem(
onClick = {},
categoryName = "Test Category"
)
}
}

View file

@ -0,0 +1,15 @@
package fr.free.nrw.commons.bookmarks.category
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Data class representing bookmarked category in DB
*
* @property categoryName
* @constructor Create empty Bookmarks category modal
*/
@Entity(tableName = "bookmarks_categories")
data class BookmarksCategoryModal(
@PrimaryKey val categoryName: String
)

View file

@ -7,8 +7,12 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils import fr.free.nrw.commons.Utils
@ -19,6 +23,8 @@ import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment
import fr.free.nrw.commons.media.MediaDetailPagerFragment import fr.free.nrw.commons.media.MediaDetailPagerFragment
import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.theme.BaseActivity
import kotlinx.coroutines.launch
import javax.inject.Inject
/** /**
@ -38,6 +44,11 @@ class CategoryDetailsActivity : BaseActivity(),
private lateinit var binding: ActivityCategoryDetailsBinding private lateinit var binding: ActivityCategoryDetailsBinding
@Inject
lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory
private val viewModel: CategoryDetailsViewModel by viewModels<CategoryDetailsViewModel> { categoryViewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -53,6 +64,15 @@ class CategoryDetailsActivity : BaseActivity(),
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
setTabs() setTabs()
setPageTitle() setPageTitle()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED){
viewModel.bookmarkState.collect {
invalidateOptionsMenu()
}
}
}
} }
/** /**
@ -73,6 +93,8 @@ class CategoryDetailsActivity : BaseActivity(),
categoriesMediaFragment.arguments = arguments categoriesMediaFragment.arguments = arguments
subCategoryListFragment.arguments = arguments subCategoryListFragment.arguments = arguments
parentCategoriesFragment.arguments = arguments parentCategoriesFragment.arguments = arguments
viewModel.onCheckIfBookmarked(categoryName!!)
} }
fragmentList.add(categoriesMediaFragment) fragmentList.add(categoriesMediaFragment)
titleList.add("MEDIA") titleList.add("MEDIA")
@ -181,6 +203,14 @@ class CategoryDetailsActivity : BaseActivity(),
Utils.handleWebUrl(this, Uri.parse(title.canonicalUri)) Utils.handleWebUrl(this, Uri.parse(title.canonicalUri))
true true
} }
R.id.menu_bookmark_current_category -> {
categoryName?.let {
viewModel.onBookmarkClick(categoryName = it)
}
true
}
android.R.id.home -> { android.R.id.home -> {
onBackPressed() onBackPressed()
true true
@ -189,6 +219,22 @@ class CategoryDetailsActivity : BaseActivity(),
} }
} }
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
menu?.run {
val bookmarkMenuItem = findItem(R.id.menu_bookmark_current_category)
if (bookmarkMenuItem != null) {
val icon = if(viewModel.bookmarkState.value){
R.drawable.menu_ic_round_star_filled_24px
} else {
R.drawable.menu_ic_round_star_border_24px
}
bookmarkMenuItem.setIcon(icon)
}
}
return super.onPrepareOptionsMenu(menu)
}
/** /**
* This method is called on backPressed of anyFragment in the activity. * This method is called on backPressed of anyFragment in the activity.
* If condition is called when mediaDetailFragment is opened. * If condition is called when mediaDetailFragment is opened.

View file

@ -0,0 +1,109 @@
package fr.free.nrw.commons.category
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModal for [CategoryDetailsActivity]
*/
class CategoryDetailsViewModel(
private val bookmarkCategoriesDao: BookmarkCategoriesDao
) : ViewModel() {
private val _bookmarkState = MutableStateFlow(false)
val bookmarkState = _bookmarkState.asStateFlow()
/**
* Used to check if bookmark exists for the given category in DB
* based on that bookmark state is updated
* @param categoryName
*/
fun onCheckIfBookmarked(categoryName: String) {
viewModelScope.launch {
val isBookmarked = bookmarkCategoriesDao.doesExist(categoryName)
_bookmarkState.update {
isBookmarked
}
}
}
/**
* Handles event when bookmark button is clicked from view
* based on that category is bookmarked or removed in/from in the DB
* and bookmark state is update as well
* @param categoryName
*/
fun onBookmarkClick(categoryName: String) {
if (_bookmarkState.value) {
deleteBookmark(categoryName)
_bookmarkState.update {
false
}
} else {
addBookmark(categoryName)
_bookmarkState.update {
true
}
}
}
/**
* Add bookmark into DB
*
* @param categoryName
*/
private fun addBookmark(categoryName: String) {
viewModelScope.launch {
val categoryItem = BookmarksCategoryModal(
categoryName = categoryName
)
bookmarkCategoriesDao.insert(categoryItem)
}
}
/**
* Delete bookmark from DB
*
* @param categoryName
*/
private fun deleteBookmark(categoryName: String) {
viewModelScope.launch {
bookmarkCategoriesDao.delete(
BookmarksCategoryModal(
categoryName = categoryName
)
)
}
}
/**
* View model factory to create [CategoryDetailsViewModel]
*
* @property bookmarkCategoriesDao
* @constructor Create empty View model factory
*/
class ViewModelFactory @Inject constructor(
private val bookmarkCategoriesDao: BookmarkCategoriesDao
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) {
CategoryDetailsViewModel(bookmarkCategoriesDao) as T
} else {
throw IllegalArgumentException("Unknown class name")
}
}
}

View file

@ -1,23 +0,0 @@
package fr.free.nrw.commons.concurrency;
import androidx.annotation.NonNull;
import fr.free.nrw.commons.BuildConfig;
public class BackgroundPoolExceptionHandler implements ExceptionHandler {
/**
* If an exception occurs on a background thread, this handler will crash for debug builds
* but fail silently for release builds.
* @param t
*/
@Override
public void onException(@NonNull final Throwable t) {
//Crash for debug build
if (BuildConfig.DEBUG) {
Thread thread = new Thread(() -> {
throw new RuntimeException(t);
});
thread.start();
}
}
}

View file

@ -0,0 +1,21 @@
package fr.free.nrw.commons.concurrency
import fr.free.nrw.commons.BuildConfig
class BackgroundPoolExceptionHandler : ExceptionHandler {
/**
* If an exception occurs on a background thread, this handler will crash for debug builds
* but fail silently for release builds.
* @param t
*/
override fun onException(t: Throwable) {
// Crash for debug build
if (BuildConfig.DEBUG) {
val thread = Thread {
throw RuntimeException(t)
}
thread.start()
}
}
}

View file

@ -1,39 +0,0 @@
package fr.free.nrw.commons.concurrency;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
class ExceptionAwareThreadPoolExecutor extends ScheduledThreadPoolExecutor {
private final ExceptionHandler exceptionHandler;
public ExceptionAwareThreadPoolExecutor(int corePoolSize, ThreadFactory threadFactory,
ExceptionHandler exceptionHandler) {
super(corePoolSize, threadFactory);
this.exceptionHandler = exceptionHandler;
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?>) {
try {
Future<?> future = (Future<?>) r;
if (future.isDone()) future.get();
} catch (CancellationException | InterruptedException e) {
//ignore
} catch (ExecutionException e) {
t = e.getCause() != null ? e.getCause() : e;
} catch (Exception e) {
t = e;
}
}
if (t != null) {
exceptionHandler.onException(t);
}
}
}

View file

@ -0,0 +1,40 @@
package fr.free.nrw.commons.concurrency
import java.util.concurrent.CancellationException
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.ThreadFactory
class ExceptionAwareThreadPoolExecutor(
corePoolSize: Int,
threadFactory: ThreadFactory,
private val exceptionHandler: ExceptionHandler?
) : ScheduledThreadPoolExecutor(corePoolSize, threadFactory) {
override fun afterExecute(r: Runnable, t: Throwable?) {
super.afterExecute(r, t)
var throwable = t
if (throwable == null && r is Future<*>) {
try {
if (r.isDone) {
r.get()
}
} catch (e: CancellationException) {
// ignore
} catch (e: InterruptedException) {
// ignore
} catch (e: ExecutionException) {
throwable = e.cause ?: e
} catch (e: Exception) {
throwable = e
}
}
throwable?.let {
exceptionHandler?.onException(it)
}
}
}

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.concurrency;
import androidx.annotation.NonNull;
public interface ExceptionHandler {
void onException(@NonNull Throwable t);
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.concurrency
interface ExceptionHandler {
fun onException(t: Throwable)
}

View file

@ -1,124 +0,0 @@
package fr.free.nrw.commons.concurrency;
import androidx.annotation.NonNull;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
/**
* This class is a thread pool which provides some additional features:
* - it sets the thread priority to a value lower than foreground priority by default, or you can
* supply your own priority
* - it gives you a way to handle exceptions thrown in the thread pool
*/
public class ThreadPoolService implements Executor {
private final ScheduledThreadPoolExecutor backgroundPool;
private ThreadPoolService(final Builder b) {
backgroundPool = new ExceptionAwareThreadPoolExecutor(b.poolSize,
new ThreadFactory() {
int count = 0;
@Override
public Thread newThread(@NonNull Runnable r) {
count++;
Thread t = new Thread(r, String.format("%s-%s", b.name, count));
//If the priority is specified out of range, we set the thread priority to Thread.MIN_PRIORITY
//It's done prevent IllegalArgumentException and to prevent setting of improper high priority for a less priority task
t.setPriority(b.priority > Thread.MAX_PRIORITY || b.priority < Thread.MIN_PRIORITY ?
Thread.MIN_PRIORITY : b.priority);
return t;
}
}, b.exceptionHandler);
}
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long time, TimeUnit timeUnit) {
return backgroundPool.schedule(callable, time, timeUnit);
}
public ScheduledFuture<?> schedule(Runnable runnable) {
return schedule(runnable, 0, TimeUnit.SECONDS);
}
public ScheduledFuture<?> schedule(Runnable runnable, long time, TimeUnit timeUnit) {
return backgroundPool.schedule(runnable, time, timeUnit);
}
public ScheduledFuture<?> scheduleAtFixedRate(final Runnable task, long initialDelay,
long period, final TimeUnit timeUnit) {
return backgroundPool.scheduleAtFixedRate(task, initialDelay, period, timeUnit);
}
public ScheduledThreadPoolExecutor executor() {
return backgroundPool;
}
public void shutdown(){
backgroundPool.shutdown();
}
@Override
public void execute(Runnable command) {
backgroundPool.execute(command);
}
/**
* Builder class for {@link ThreadPoolService}
*/
public static class Builder {
//Required
private final String name;
//Optional
private int poolSize = 1;
private int priority = Thread.MIN_PRIORITY;
private ExceptionHandler exceptionHandler = null;
/**
* @param name the name of the threads in the service. if there are N threads,
* the thread names will be like name-1, name-2, name-3,...,name-N
*/
public Builder(@NonNull String name) {
this.name = name;
}
/**
* @param poolSize the number of threads to keep in the pool
* @throws IllegalArgumentException if size of pool <=0
*/
public Builder setPoolSize(int poolSize) throws IllegalArgumentException {
if (poolSize <= 0) {
throw new IllegalArgumentException("Pool size must be grater than 0");
}
this.poolSize = poolSize;
return this;
}
/**
* @param priority Priority of the threads in the service. You can supply a constant from
* {@link java.lang.Thread} or
* specify your own priority in the range 1(MIN_PRIORITY) to 10(MAX_PRIORITY)
* By default, the priority is set to {@link java.lang.Thread#MIN_PRIORITY}
*/
public Builder setPriority(int priority) {
this.priority = priority;
return this;
}
/**
* @param handler The handler to use to handle exceptions in the service
*/
public Builder setExceptionHandler(ExceptionHandler handler) {
this.exceptionHandler = handler;
return this;
}
public ThreadPoolService build() {
return new ThreadPoolService(this);
}
}
}

View file

@ -0,0 +1,122 @@
package fr.free.nrw.commons.concurrency
import java.util.concurrent.Callable
import java.util.concurrent.Executor
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.ThreadFactory
import java.util.concurrent.TimeUnit
/**
* This class is a thread pool which provides some additional features:
* - it sets the thread priority to a value lower than foreground priority by default, or you can
* supply your own priority
* - it gives you a way to handle exceptions thrown in the thread pool
*/
class ThreadPoolService private constructor(builder: Builder) : Executor {
private val backgroundPool: ScheduledThreadPoolExecutor = ExceptionAwareThreadPoolExecutor(
builder.poolSize,
object : ThreadFactory {
private var count = 0
override fun newThread(r: Runnable): Thread {
count++
val t = Thread(r, "${builder.name}-$count")
// If the priority is specified out of range, we set the thread priority to
// Thread.MIN_PRIORITY
// It's done to prevent IllegalArgumentException and to prevent setting of
// improper high priority for a less priority task
t.priority =
if (
builder.priority > Thread.MAX_PRIORITY
||
builder.priority < Thread.MIN_PRIORITY
) {
Thread.MIN_PRIORITY
} else {
builder.priority
}
return t
}
},
builder.exceptionHandler
)
fun <V> schedule(callable: Callable<V>, time: Long, timeUnit: TimeUnit): ScheduledFuture<V> {
return backgroundPool.schedule(callable, time, timeUnit)
}
fun schedule(runnable: Runnable): ScheduledFuture<*> {
return schedule(runnable, 0, TimeUnit.SECONDS)
}
fun schedule(runnable: Runnable, time: Long, timeUnit: TimeUnit): ScheduledFuture<*> {
return backgroundPool.schedule(runnable, time, timeUnit)
}
fun scheduleAtFixedRate(
task: Runnable,
initialDelay: Long,
period: Long,
timeUnit: TimeUnit
): ScheduledFuture<*> {
return backgroundPool.scheduleWithFixedDelay(task, initialDelay, period, timeUnit)
}
fun executor(): ScheduledThreadPoolExecutor {
return backgroundPool
}
fun shutdown() {
backgroundPool.shutdown()
}
override fun execute(command: Runnable) {
backgroundPool.execute(command)
}
/**
* Builder class for [ThreadPoolService]
*/
class Builder(val name: String) {
var poolSize: Int = 1
var priority: Int = Thread.MIN_PRIORITY
var exceptionHandler: ExceptionHandler? = null
/**
* @param poolSize the number of threads to keep in the pool
* @throws IllegalArgumentException if size of pool <= 0
*/
fun setPoolSize(poolSize: Int): Builder {
if (poolSize <= 0) {
throw IllegalArgumentException("Pool size must be greater than 0")
}
this.poolSize = poolSize
return this
}
/**
* @param priority Priority of the threads in the service. You can supply a constant from
* [java.lang.Thread] or
* specify your own priority in the range 1(MIN_PRIORITY)
* to 10(MAX_PRIORITY)
* By default, the priority is set to [java.lang.Thread.MIN_PRIORITY]
*/
fun setPriority(priority: Int): Builder {
this.priority = priority
return this
}
/**
* @param handler The handler to use to handle exceptions in the service
*/
fun setExceptionHandler(handler: ExceptionHandler): Builder {
exceptionHandler = handler
return this
}
fun build(): ThreadPoolService {
return ThreadPoolService(this)
}
}
}

View file

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

View file

@ -2,7 +2,6 @@ package fr.free.nrw.commons.contributions
import androidx.paging.PagedList.BoundaryCallback import androidx.paging.PagedList.BoundaryCallback
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import io.reactivex.Scheduler import io.reactivex.Scheduler
@ -31,10 +30,7 @@ class ContributionBoundaryCallback
* network * network
*/ */
override fun onZeroItemsLoaded() { override fun onZeroItemsLoaded() {
if (sessionManager.userName != null) { refreshList()
mediaClient.resetUserNameContinuation(sessionManager.userName!!)
}
fetchContributions()
} }
/** /**
@ -52,9 +48,25 @@ class ContributionBoundaryCallback
} }
/** /**
* Fetches contributions using the MediaWiki API * Fetch list from network and save it to local DB.
*
* @param onRefreshFinish callback to invoke when operations finishes
* with either error or success.
*/ */
private fun fetchContributions() { fun refreshList(onRefreshFinish: () -> Unit = {}){
if (sessionManager.userName != null) {
mediaClient.resetUserNameContinuation(sessionManager.userName!!)
}
fetchContributions(onRefreshFinish)
}
/**
* Fetches contributions using the MediaWiki API
*
* @param onRefreshFinish callback to invoke when operations finishes
* with either error or success.
*/
private fun fetchContributions(onRefreshFinish: () -> Unit = {}) {
if (sessionManager.userName != null) { if (sessionManager.userName != null) {
userName userName
?.let { userName -> ?.let { userName ->
@ -65,12 +77,15 @@ class ContributionBoundaryCallback
Contribution(media = media, state = Contribution.STATE_COMPLETED) Contribution(media = media, state = Contribution.STATE_COMPLETED)
} }
}.subscribeOn(ioThreadScheduler) }.subscribeOn(ioThreadScheduler)
.subscribe(::saveContributionsToDB) { error: Throwable -> .subscribe({ list ->
saveContributionsToDB(list, onRefreshFinish)
},{ error ->
onRefreshFinish()
Timber.e( Timber.e(
"Failed to fetch contributions: %s", "Failed to fetch contributions: %s",
error.message, error.message,
) )
} })
}?.let { }?.let {
compositeDisposable.add( compositeDisposable.add(
it, it,
@ -83,13 +98,16 @@ class ContributionBoundaryCallback
/** /**
* Saves the contributions the the local DB * Saves the contributions the the local DB
*
* @param onRefreshFinish callback to invoke when successfully saved to DB.
*/ */
private fun saveContributionsToDB(contributions: List<Contribution>) { private fun saveContributionsToDB(contributions: List<Contribution>, onRefreshFinish: () -> Unit) {
compositeDisposable.add( compositeDisposable.add(
repository repository
.save(contributions) .save(contributions)
.subscribeOn(ioThreadScheduler) .subscribeOn(ioThreadScheduler)
.subscribe { longs: List<Long?>? -> .subscribe { longs: List<Long?>? ->
onRefreshFinish()
repository["last_fetch_timestamp"] = System.currentTimeMillis() repository["last_fetch_timestamp"] = System.currentTimeMillis()
}, },
) )

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.contributions; package fr.free.nrw.commons.contributions;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import fr.free.nrw.commons.BasePresenter; import fr.free.nrw.commons.BasePresenter;
/** /**
@ -17,5 +18,8 @@ public class ContributionsListContract {
} }
public interface UserActionListener extends BasePresenter<View> { public interface UserActionListener extends BasePresenter<View> {
void refreshList(SwipeRefreshLayout swipeRefreshLayout);
} }
} }

View file

@ -191,6 +191,15 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
initAdapter(); initAdapter();
// pull down to refresh only enabled for self user.
if(Objects.equals(sessionManager.getUserName(), userName)){
binding.swipeRefreshLayout.setOnRefreshListener(() -> {
contributionsListPresenter.refreshList(binding.swipeRefreshLayout);
});
} else {
binding.swipeRefreshLayout.setEnabled(false);
}
return binding.getRoot(); return binding.getRoot();
} }

View file

@ -8,14 +8,15 @@ import androidx.paging.DataSource;
import androidx.paging.DataSource.Factory; import androidx.paging.DataSource.Factory;
import androidx.paging.LivePagedListBuilder; import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList; import androidx.paging.PagedList;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener; import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import io.reactivex.Scheduler; import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.CompositeDisposable;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import javax.inject.Inject; import javax.inject.Inject;
import javax.inject.Named; import javax.inject.Named;
import kotlin.Unit;
import kotlin.jvm.functions.Function0;
/** /**
* The presenter class for Contributions * The presenter class for Contributions
@ -95,4 +96,17 @@ public class ContributionsListPresenter implements UserActionListener {
contributionBoundaryCallback.dispose(); contributionBoundaryCallback.dispose();
} }
/**
* It is used to refresh list.
*
* @param swipeRefreshLayout used to stop refresh animation when
* refresh finishes.
*/
@Override
public void refreshList(final SwipeRefreshLayout swipeRefreshLayout) {
contributionBoundaryCallback.refreshList(() -> {
swipeRefreshLayout.setRefreshing(false);
return Unit.INSTANCE;
});
}
} }

View file

@ -5,14 +5,11 @@ import android.app.NotificationManager;
import android.app.WallpaperManager; import android.app.WallpaperManager;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
import com.facebook.common.executors.CallerThreadExecutor; import com.facebook.common.executors.CallerThreadExecutor;
@ -25,7 +22,6 @@ import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.request.ImageRequest; import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder; import com.facebook.imagepipeline.request.ImageRequestBuilder;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import java.io.IOException;
import timber.log.Timber; import timber.log.Timber;
public class SetWallpaperWorker extends Worker { public class SetWallpaperWorker extends Worker {

View file

@ -42,6 +42,7 @@ object FolderDeletionHelper {
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
.setCancelable(false)
.setMessage( .setMessage(
context.getString( context.getString(
R.string.custom_selector_confirm_deletion_message, R.string.custom_selector_confirm_deletion_message,

View file

@ -250,7 +250,7 @@ class CustomSelectorActivity :
val selectedImages: ArrayList<Image> = val selectedImages: ArrayList<Image> =
result.data!! result.data!!
.getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!!
viewModel?.selectedImages?.value = selectedImages viewModel.selectedImages?.value = selectedImages
} }
} }
@ -268,6 +268,7 @@ class CustomSelectorActivity :
*/ */
private fun showWelcomeDialog() { private fun showWelcomeDialog() {
val dialog = Dialog(this) val dialog = Dialog(this)
dialog.setCancelable(false)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(R.layout.custom_selector_info_dialog) dialog.setContentView(R.layout.custom_selector_info_dialog)
(dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() } (dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() }
@ -683,6 +684,7 @@ class CustomSelectorActivity :
*/ */
private fun displayUploadLimitWarning() { private fun displayUploadLimitWarning() {
val dialog = Dialog(this) val dialog = Dialog(this)
dialog.setCancelable(false)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(R.layout.custom_selector_limit_dialog) dialog.setContentView(R.layout.custom_selector_limit_dialog)
(dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener { dialog.dismiss() } (dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener { dialog.dismiss() }

View file

@ -18,7 +18,7 @@ class DBOpenHelper(
companion object { companion object {
private const val DATABASE_NAME = "commons.db" private const val DATABASE_NAME = "commons.db"
private const val DATABASE_VERSION = 20 private const val DATABASE_VERSION = 21
const val CONTRIBUTIONS_TABLE = "contributions" const val CONTRIBUTIONS_TABLE = "contributions"
private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS %s" private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS %s"
} }

View file

@ -3,6 +3,8 @@ package fr.free.nrw.commons.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.bookmarks.category.BookmarksCategoryModal
import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatus
@ -21,8 +23,8 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao
* *
*/ */
@Database( @Database(
entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class, BookmarksCategoryModal::class],
version = 18, version = 19,
exportSchema = false, exportSchema = false,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -38,4 +40,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun NotForUploadStatusDao(): NotForUploadStatusDao abstract fun NotForUploadStatusDao(): NotForUploadStatusDao
abstract fun ReviewDao(): ReviewDao abstract fun ReviewDao(): ReviewDao
abstract fun bookmarkCategoriesDao(): BookmarkCategoriesDao
} }

View file

@ -240,7 +240,7 @@ class DescriptionEditActivity :
applicationContext, applicationContext,
media, media,
updatedWikiText, updatedWikiText,
)?.subscribeOn(Schedulers.io()) ).subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread()) ?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(Consumer<Boolean> { s: Boolean? -> Timber.d("Descriptions are added.") }) ?.subscribe(Consumer<Boolean> { s: Boolean? -> Timber.d("Descriptions are added.") })
?.let { ?.let {

View file

@ -15,6 +15,7 @@ import dagger.Provides
import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatusDao
@ -221,6 +222,10 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
fun providesReviewDao(appDatabase: AppDatabase): ReviewDao = fun providesReviewDao(appDatabase: AppDatabase): ReviewDao =
appDatabase.ReviewDao() appDatabase.ReviewDao()
@Provides
fun providesBookmarkCategoriesDao (appDatabase: AppDatabase): BookmarkCategoriesDao =
appDatabase.bookmarkCategoriesDao()
@Provides @Provides
fun providesContentResolver(context: Context): ContentResolver = fun providesContentResolver(context: Context): ContentResolver =
context.contentResolver context.contentResolver

View file

@ -4,6 +4,7 @@ import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
import fr.free.nrw.commons.bookmarks.BookmarkFragment import fr.free.nrw.commons.bookmarks.BookmarkFragment
import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment import fr.free.nrw.commons.bookmarks.BookmarkListRootFragment
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesFragment
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment import fr.free.nrw.commons.bookmarks.items.BookmarkItemsFragment
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsFragment
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment
@ -97,6 +98,9 @@ abstract class FragmentBuilderModule {
@ContributesAndroidInjector(modules = [BookmarkItemsFragmentModule::class]) @ContributesAndroidInjector(modules = [BookmarkItemsFragmentModule::class])
abstract fun bindBookmarkItemListFragment(): BookmarkItemsFragment abstract fun bindBookmarkItemListFragment(): BookmarkItemsFragment
@ContributesAndroidInjector
abstract fun bindBookmarkCategoriesListFragment(): BookmarkCategoriesFragment
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun bindReviewOutOfContextFragment(): ReviewImageFragment abstract fun bindReviewOutOfContextFragment(): ReviewImageFragment

View file

@ -17,6 +17,7 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.location.Location; import android.location.Location;
import android.location.LocationManager; import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.text.Html; import android.text.Html;
@ -167,7 +168,11 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState); super.onViewCreated(view, savedInstanceState);
setSearchThisAreaButtonVisibility(false); setSearchThisAreaButtonVisibility(false);
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY));
} else {
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
}
initNetworkBroadCastReceiver(); initNetworkBroadCastReceiver();
locationPermissionsHelper = new LocationPermissionsHelper(getActivity(),locationManager,this); locationPermissionsHelper = new LocationPermissionsHelper(getActivity(),locationManager,this);
if (presenter == null) { if (presenter == null) {

View file

@ -67,6 +67,7 @@ public class RecentSearchesFragment extends CommonsDaggerSupportFragment {
.setPositiveButton(android.R.string.yes, .setPositiveButton(android.R.string.yes,
(dialog, which) -> setDeleteRecentPositiveButton(context, dialog)) (dialog, which) -> setDeleteRecentPositiveButton(context, dialog))
.setNegativeButton(android.R.string.no, null) .setNegativeButton(android.R.string.no, null)
.setCancelable(false)
.create() .create()
.show(); .show();
} }
@ -94,6 +95,7 @@ public class RecentSearchesFragment extends CommonsDaggerSupportFragment {
.setPositiveButton(getString(R.string.delete).toUpperCase(Locale.ROOT), .setPositiveButton(getString(R.string.delete).toUpperCase(Locale.ROOT),
((dialog, which) -> setDeletePositiveButton(context, dialog, position))) ((dialog, which) -> setDeletePositiveButton(context, dialog, position)))
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setCancelable(false)
.create() .create()
.show(); .show();
} }

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.feedback
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.Html import android.text.Html
import android.text.Spanned import android.text.Spanned
@ -26,11 +27,13 @@ class FeedbackDialog(
private val onFeedbackSubmitCallback: OnFeedbackSubmitCallback) : Dialog(context) { private val onFeedbackSubmitCallback: OnFeedbackSubmitCallback) : Dialog(context) {
private var _binding: DialogFeedbackBinding? = null private var _binding: DialogFeedbackBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
// TODO("Remove Deprecation") Issue : #6002 // Refactored to handle deprecation for Html.fromHtml()
// 'fromHtml(String!): Spanned!' is deprecated. Deprecated in Java private var feedbackDestinationHtml: Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@Suppress("DEPRECATION") Html.fromHtml(context.getString(R.string.feedback_destination_note), Html.FROM_HTML_MODE_LEGACY)
private var feedbackDestinationHtml: Spanned = Html.fromHtml( } else {
context.getString(R.string.feedback_destination_note)) @Suppress("DEPRECATION")
Html.fromHtml(context.getString(R.string.feedback_destination_note))
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
@ -43,6 +46,7 @@ class FeedbackDialog(
// 'SOFT_INPUT_ADJUST_RESIZE: Int' is deprecated. Deprecated in Java // 'SOFT_INPUT_ADJUST_RESIZE: Int' is deprecated. Deprecated in Java
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
binding.btnCancel.setOnClickListener { dismiss() }
binding.btnSubmitFeedback.setOnClickListener { binding.btnSubmitFeedback.setOnClickListener {
try { try {
submitFeedback() submitFeedback()

View file

@ -263,17 +263,8 @@ object FilePicker : Constants {
) { ) {
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
try { try {
val photoPath = result.data?.data val files = getFilesFromGalleryPictures(result.data, activity)
val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!) callbacks.onImagesPicked(files, ImageSource.DOCUMENTS, restoreType(activity))
callbacks.onImagesPicked(
singleFileList(photoFile),
ImageSource.DOCUMENTS,
restoreType(activity)
)
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity)) callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity))

View file

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

View file

@ -10,6 +10,7 @@ import android.os.Parcelable
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import fr.free.nrw.commons.upload.FileUtils import fr.free.nrw.commons.upload.FileUtils
import fr.free.nrw.commons.upload.ImageCoordinates
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
@ -87,9 +88,7 @@ class UploadableFile : Parcelable {
fun hasLocation(): Boolean { fun hasLocation(): Boolean {
return try { return try {
val exif = ExifInterface(file.absolutePath) val exif = ExifInterface(file.absolutePath)
val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) ImageCoordinates(exif, null).imageCoordsExists
val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE)
latitude != null && longitude != null
} catch (e: IOException) { } catch (e: IOException) {
Timber.tag("UploadableFile").d(e) Timber.tag("UploadableFile").d(e)
false false

View file

@ -5,7 +5,6 @@ import android.util.Log
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.Locale
import java.util.concurrent.Executor import java.util.concurrent.Executor
import ch.qos.logback.classic.LoggerContext import ch.qos.logback.classic.LoggerContext

View file

@ -31,6 +31,7 @@ import com.google.android.material.snackbar.Snackbar;
import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media; import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.bookmarks.models.Bookmark; import fr.free.nrw.commons.bookmarks.models.Bookmark;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider; import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider;
@ -211,6 +212,13 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
snackbar.show(); snackbar.show();
updateBookmarkState(item); updateBookmarkState(item);
return true; return true;
case R.id.menu_copy_link:
String uri = m.getPageTitle().getCanonicalUri();
Utils.copy("shareLink", uri, requireContext());
Timber.d("Copied share link to clipboard: %s", uri);
Toast.makeText(requireContext(), getString(R.string.menu_link_copied),
Toast.LENGTH_SHORT).show();
return true;
case R.id.menu_share_current_image: case R.id.menu_share_current_image:
Intent shareIntent = new Intent(Intent.ACTION_SEND); Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain"); shareIntent.setType("text/plain");
@ -283,6 +291,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
builder.setItems(R.array.report_violation_options, (dialog, which) -> { builder.setItems(R.array.report_violation_options, (dialog, which) -> {
sendReportEmail(media, values[which]); sendReportEmail(media, values[which]);
}); });
builder.setNegativeButton(R.string.cancel, (dialog, which) -> {});
builder.setCancelable(false); builder.setCancelable(false);
builder.show(); builder.show();
} }
@ -390,6 +399,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
if (m != null) { if (m != null) {
// Enable default set of actions, then re-enable different set of actions only if it is a failed contrib // Enable default set of actions, then re-enable different set of actions only if it is a failed contrib
menu.findItem(R.id.menu_browser_current_image).setEnabled(true).setVisible(true); menu.findItem(R.id.menu_browser_current_image).setEnabled(true).setVisible(true);
menu.findItem(R.id.menu_copy_link).setEnabled(true).setVisible(true);
menu.findItem(R.id.menu_share_current_image).setEnabled(true).setVisible(true); menu.findItem(R.id.menu_share_current_image).setEnabled(true).setVisible(true);
menu.findItem(R.id.menu_download_current_image).setEnabled(true).setVisible(true); menu.findItem(R.id.menu_download_current_image).setEnabled(true).setVisible(true);
menu.findItem(R.id.menu_bookmark_current_image).setEnabled(true).setVisible(true); menu.findItem(R.id.menu_bookmark_current_image).setEnabled(true).setVisible(true);
@ -423,6 +433,8 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
case Contribution.STATE_QUEUED: case Contribution.STATE_QUEUED:
menu.findItem(R.id.menu_browser_current_image).setEnabled(false) menu.findItem(R.id.menu_browser_current_image).setEnabled(false)
.setVisible(false); .setVisible(false);
menu.findItem(R.id.menu_copy_link).setEnabled(false)
.setVisible(false);
menu.findItem(R.id.menu_share_current_image).setEnabled(false) menu.findItem(R.id.menu_share_current_image).setEnabled(false)
.setVisible(false); .setVisible(false);
menu.findItem(R.id.menu_download_current_image).setEnabled(false) menu.findItem(R.id.menu_download_current_image).setEnabled(false)
@ -440,6 +452,8 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple
} else { } else {
menu.findItem(R.id.menu_browser_current_image).setEnabled(false) menu.findItem(R.id.menu_browser_current_image).setEnabled(false)
.setVisible(false); .setVisible(false);
menu.findItem(R.id.menu_copy_link).setEnabled(false)
.setVisible(false);
menu.findItem(R.id.menu_share_current_image).setEnabled(false) menu.findItem(R.id.menu_share_current_image).setEnabled(false)
.setVisible(false); .setVisible(false);
menu.findItem(R.id.menu_download_current_image).setEnabled(false) menu.findItem(R.id.menu_download_current_image).setEnabled(false)

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.mwapi
import android.text.TextUtils import android.text.TextUtils
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser
import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.campaigns.CampaignResponseDTO import fr.free.nrw.commons.campaigns.CampaignResponseDTO
import fr.free.nrw.commons.explore.depictions.DepictsClient import fr.free.nrw.commons.explore.depictions.DepictsClient
@ -10,6 +11,7 @@ import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse
import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.model.ItemsClass import fr.free.nrw.commons.nearby.model.ItemsClass
import fr.free.nrw.commons.nearby.model.NearbyQueryParams
import fr.free.nrw.commons.nearby.model.NearbyResponse import fr.free.nrw.commons.nearby.model.NearbyResponse
import fr.free.nrw.commons.nearby.model.PlaceBindings import fr.free.nrw.commons.nearby.model.PlaceBindings
import fr.free.nrw.commons.profile.achievements.FeaturedImages import fr.free.nrw.commons.profile.achievements.FeaturedImages
@ -175,7 +177,7 @@ class OkHttpJsonApiClient @Inject constructor(
.build() .build()
val response: Response = okHttpClient.newCall(request).execute() val response: Response = okHttpClient.newCall(request).execute()
if (response.body != null && response.isSuccessful) { if (response.body != null && response.isSuccessful) {
val json: String = response.body!!.string() ?: return@fromCallable null val json: String = response.body!!.string()
try { try {
return@fromCallable gson.fromJson<UpdateAvatarResponse>( return@fromCallable gson.fromJson<UpdateAvatarResponse>(
json, json,
@ -330,36 +332,130 @@ class OkHttpJsonApiClient @Inject constructor(
throw Exception(response.message) throw Exception(response.message)
} }
/**
* Returns the count of items in the specified area by querying Wikidata.
*
* @param queryParams: a `NearbyQueryParam` specifying the geographical area.
* @return The count of items in the specified area.
*/
@Throws(Exception::class)
fun getNearbyItemCount(
queryParams: NearbyQueryParams
): Int {
val wikidataQuery: String = when (queryParams) {
is NearbyQueryParams.Rectangular -> {
val westCornerLat = queryParams.screenTopRight.latitude
val westCornerLong = queryParams.screenTopRight.longitude
val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
FileUtils.readFromResource("/queries/rectangle_query_for_item_count.rq")
.replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
}
is NearbyQueryParams.Radial -> {
FileUtils.readFromResource("/queries/radius_query_for_item_count.rq")
.replace(
"\${LAT}",
String.format(Locale.ROOT, "%.4f", queryParams.center.latitude)
)
.replace(
"\${LONG}",
String.format(Locale.ROOT, "%.4f", queryParams.center.longitude)
)
.replace("\${RAD}", String.format(Locale.ROOT, "%.2f", queryParams.radiusInKm))
}
}
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
.newBuilder()
.addQueryParameter("query", wikidataQuery)
.addQueryParameter("format", "json")
val request: Request = Request.Builder()
.url(urlBuilder.build())
.build()
val response = okHttpClient.newCall(request).execute()
if (response.body != null && response.isSuccessful) {
val json = response.body!!.string()
return JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("results")
.getAsJsonArray("bindings").get(0).getAsJsonObject().getAsJsonObject("itemCount")
.get("value").asInt
}
throw Exception(response.message)
}
@Throws(Exception::class) @Throws(Exception::class)
fun getNearbyPlaces( fun getNearbyPlaces(
screenTopRight: LatLng, queryParams: NearbyQueryParams, language: String,
screenBottomLeft: LatLng, language: String,
shouldQueryForMonuments: Boolean, customQuery: String? shouldQueryForMonuments: Boolean, customQuery: String?
): List<Place>? { ): List<Place>? {
Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString())
val locale = Locale.ROOT;
val wikidataQuery: String = if (customQuery != null) { val wikidataQuery: String = if (customQuery != null) {
customQuery when (queryParams) {
} else if (!shouldQueryForMonuments) { is NearbyQueryParams.Rectangular -> {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq") val westCornerLat = queryParams.screenTopRight.latitude
} else { val westCornerLong = queryParams.screenTopRight.longitude
FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq") val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
customQuery
.replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
}
is NearbyQueryParams.Radial -> {
Timber.e(
"%s%s",
"okHttpJsonApiClient.getNearbyPlaces invoked with custom query",
"and radial coordinates. This is currently not supported."
)
""
}
}
} else when (queryParams) {
is NearbyQueryParams.Radial -> {
val placeHolderQuery: String = if (!shouldQueryForMonuments) {
FileUtils.readFromResource("/queries/radius_query_for_nearby.rq")
} else {
FileUtils.readFromResource("/queries/radius_query_for_nearby_monuments.rq")
}
placeHolderQuery.replace(
"\${LAT}", String.format(locale, "%.4f", queryParams.center.latitude)
).replace(
"\${LONG}", String.format(locale, "%.4f", queryParams.center.longitude)
)
.replace("\${RAD}", String.format(locale, "%.2f", queryParams.radiusInKm))
}
is NearbyQueryParams.Rectangular -> {
val placeHolderQuery: String = if (!shouldQueryForMonuments) {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq")
} else {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq")
}
val westCornerLat = queryParams.screenTopRight.latitude
val westCornerLong = queryParams.screenTopRight.longitude
val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
placeHolderQuery
.replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
}
} }
val westCornerLat = screenTopRight.latitude
val westCornerLong = screenTopRight.longitude
val eastCornerLat = screenBottomLeft.latitude
val eastCornerLong = screenBottomLeft.longitude
val query = wikidataQuery
.replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
.newBuilder() .newBuilder()
.addQueryParameter("query", query) .addQueryParameter("query", wikidataQuery)
.addQueryParameter("format", "json") .addQueryParameter("format", "json")
val request: Request = Request.Builder() val request: Request = Request.Builder()

View file

@ -161,7 +161,10 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() {
override fun onFeedbackSubmit(feedback: Feedback) { override fun onFeedbackSubmit(feedback: Feedback) {
uploadFeedback(feedback) uploadFeedback(feedback)
} }
}).show() }).apply {
setCancelable(false)
show()
}
} }
/** /**

View file

@ -94,6 +94,7 @@ class MoreBottomSheetLoggedOutFragment : BottomSheetDialogFragment() {
.setMessage(R.string.feedback_sharing_data_alert) .setMessage(R.string.feedback_sharing_data_alert)
.setCancelable(false) .setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> sendFeedback() } .setPositiveButton(R.string.ok) { _, _ -> sendFeedback() }
.setNegativeButton(R.string.cancel){_,_ -> }
.show() .show()
} }

View file

@ -20,4 +20,8 @@ public class MarkerPlaceGroup {
public boolean getIsBookmarked() { public boolean getIsBookmarked() {
return isBookmarked; return isBookmarked;
} }
public void setIsBookmarked(boolean isBookmarked) {
this.isBookmarked = isBookmarked;
}
} }

View file

@ -20,8 +20,8 @@ import timber.log.Timber;
public class NearbyController extends MapController { public class NearbyController extends MapController {
private static final int MAX_RESULTS = 1000;
private final NearbyPlaces nearbyPlaces; private final NearbyPlaces nearbyPlaces;
public static final int MAX_RESULTS = 1000;
public static double currentLocationSearchRadius = 10.0; //in kilometers public static double currentLocationSearchRadius = 10.0; //in kilometers
public static LatLng currentLocation; // Users latest fetched location public static LatLng currentLocation; // Users latest fetched location
public static LatLng latestSearchLocation; // Can be current and camera target on search this area button is used public static LatLng latestSearchLocation; // Can be current and camera target on search this area button is used
@ -196,8 +196,9 @@ public class NearbyController extends MapController {
return null; return null;
} }
List<Place> places = nearbyPlaces.getFromWikidataQuery(screenTopRight, screenBottomLeft, List<Place> places = nearbyPlaces.getFromWikidataQuery(currentLatLng, screenTopRight,
Locale.getDefault().getLanguage(), shouldQueryForMonuments, customQuery); screenBottomLeft, Locale.getDefault().getLanguage(), shouldQueryForMonuments,
customQuery);
if (null != places && places.size() > 0) { if (null != places && places.size() > 0) {
LatLng[] boundaryCoordinates = { LatLng[] boundaryCoordinates = {

View file

@ -1,6 +1,8 @@
package fr.free.nrw.commons.nearby; package fr.free.nrw.commons.nearby;
import android.location.Location;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import fr.free.nrw.commons.nearby.model.NearbyQueryParams;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -101,6 +103,7 @@ public class NearbyPlaces {
* Retrieves a list of places from a Wikidata query based on screen coordinates and optional * Retrieves a list of places from a Wikidata query based on screen coordinates and optional
* parameters. * parameters.
* *
* @param centerPoint The center of the map, used for radius queries if required.
* @param screenTopRight The top right corner of the screen (latitude, longitude). * @param screenTopRight The top right corner of the screen (latitude, longitude).
* @param screenBottomLeft The bottom left corner of the screen (latitude, longitude). * @param screenBottomLeft The bottom left corner of the screen (latitude, longitude).
* @param lang The language for the query. * @param lang The language for the query.
@ -111,13 +114,70 @@ public class NearbyPlaces {
* @throws Exception If an error occurs during the retrieval process. * @throws Exception If an error occurs during the retrieval process.
*/ */
public List<Place> getFromWikidataQuery( public List<Place> getFromWikidataQuery(
final fr.free.nrw.commons.location.LatLng centerPoint,
final fr.free.nrw.commons.location.LatLng screenTopRight, final fr.free.nrw.commons.location.LatLng screenTopRight,
final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang, final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang,
final boolean shouldQueryForMonuments, final boolean shouldQueryForMonuments,
@Nullable final String customQuery) throws Exception { @Nullable final String customQuery) throws Exception {
return okHttpJsonApiClient if (customQuery != null) {
.getNearbyPlaces(screenTopRight, screenBottomLeft, lang, shouldQueryForMonuments, return okHttpJsonApiClient
customQuery); .getNearbyPlaces(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
shouldQueryForMonuments,
customQuery);
}
final int lowerLimit = 1000, upperLimit = 1500;
final float[] results = new float[1];
Location.distanceBetween(centerPoint.getLatitude(), screenTopRight.getLongitude(),
centerPoint.getLatitude(), screenBottomLeft.getLongitude(), results);
final float longGap = results[0] / 1000f;
Location.distanceBetween(screenTopRight.getLatitude(), centerPoint.getLongitude(),
screenBottomLeft.getLatitude(), centerPoint.getLongitude(), results);
final float latGap = results[0] / 1000f;
if (Math.max(longGap, latGap) < 100f) {
final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft));
if (itemCount < upperLimit) {
return okHttpJsonApiClient.getNearbyPlaces(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
shouldQueryForMonuments, null);
}
}
// minRadius, targetRadius and maxRadius are radii in decameters
// unlike other radii here, which are in kilometers, to avoid looping over
// floating point values
int minRadius = 0, maxRadius = Math.round(Math.min(300f, Math.min(longGap, latGap))) * 100;
int targetRadius = maxRadius / 2;
while (minRadius < maxRadius) {
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f));
if (itemCount >= lowerLimit && itemCount < upperLimit) {
break;
}
if (targetRadius > maxRadius / 2 && itemCount < lowerLimit / 5) { // fast forward
minRadius = targetRadius;
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
minRadius = targetRadius;
if (itemCount < lowerLimit / 10 && minRadius < maxRadius) { // fast forward again
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
minRadius = targetRadius;
}
continue;
}
if (itemCount < upperLimit) {
minRadius = targetRadius;
} else {
maxRadius = targetRadius - 1;
}
}
return okHttpJsonApiClient.getNearbyPlaces(
new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f), lang, shouldQueryForMonuments,
null);
} }
/** /**

View file

@ -5,6 +5,7 @@ import android.os.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.room.Embedded;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.PrimaryKey; import androidx.room.PrimaryKey;
import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LatLng;
@ -24,6 +25,7 @@ public class Place implements Parcelable {
public String name; public String name;
private Label label; private Label label;
private String longDescription; private String longDescription;
@Embedded
public LatLng location; public LatLng location;
@PrimaryKey @NonNull @PrimaryKey @NonNull
public String entityID; public String entityID;

View file

@ -5,10 +5,12 @@ import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.widget.RelativeLayout
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
@ -39,12 +41,18 @@ fun placeAdapterDelegate(
showOrHideAndScrollToIfLast() showOrHideAndScrollToIfLast()
onItemClick?.invoke(item) onItemClick?.invoke(item)
} }
root.setOnFocusChangeListener { view1: View?, hasFocus: Boolean -> root.setOnFocusChangeListener { _: View?, hasFocus: Boolean ->
val parentView = root.parent.parent.parent as? RelativeLayout
val bottomSheetBehavior = parentView?.let { BottomSheetBehavior.from(it) }
// Hide button layout if focus is lost, otherwise show it if it's not already visible
if (!hasFocus && nearbyButtonLayout.buttonLayout.isShown) { if (!hasFocus && nearbyButtonLayout.buttonLayout.isShown) {
nearbyButtonLayout.buttonLayout.visibility = GONE nearbyButtonLayout.buttonLayout.visibility = GONE
} else if (hasFocus && !nearbyButtonLayout.buttonLayout.isShown) { } else if (hasFocus && !nearbyButtonLayout.buttonLayout.isShown) {
showOrHideAndScrollToIfLast() if (bottomSheetBehavior?.state != BottomSheetBehavior.STATE_HIDDEN) {
onItemClick?.invoke(item) showOrHideAndScrollToIfLast()
onItemClick?.invoke(item)
}
} }
} }
nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult) } nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult) }

View file

@ -5,6 +5,7 @@ import androidx.room.Insert;
import androidx.room.OnConflictStrategy; import androidx.room.OnConflictStrategy;
import androidx.room.Query; import androidx.room.Query;
import io.reactivex.Completable; import io.reactivex.Completable;
import java.util.List;
/** /**
* Data Access Object (DAO) for accessing the Place entity in the database. * Data Access Object (DAO) for accessing the Place entity in the database.
@ -32,6 +33,20 @@ public abstract class PlaceDao {
@Query("SELECT * from place WHERE entityID=:entity") @Query("SELECT * from place WHERE entityID=:entity")
public abstract Place getPlace(String entity); public abstract Place getPlace(String entity);
/**
* Retrieves a list of places within the specified rectangular area.
*
* @param latBegin Latitudinal lower bound
* @param lngBegin Longitudinal lower bound
* @param latEnd Latitudinal upper bound, should be greater than `latBegin`
* @param lngEnd Longitudinal upper bound, should be greater than `lngBegin`
* @return The list of places within the specified rectangular geographical area.
*/
@Query("SELECT * from place WHERE name!='' AND latitude>=:latBegin AND longitude>=:lngBegin "
+ "AND latitude<:latEnd AND longitude<:lngEnd")
public abstract List<Place> fetchPlaces(double latBegin, double lngBegin,
double latEnd, double lngEnd);
/** /**
* Saves a Place object asynchronously into the database. * Saves a Place object asynchronously into the database.
*/ */

View file

@ -1,7 +1,11 @@
package fr.free.nrw.commons.nearby; package fr.free.nrw.commons.nearby;
import fr.free.nrw.commons.location.LatLng;
import io.reactivex.Completable; import io.reactivex.Completable;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject; import javax.inject.Inject;
import timber.log.Timber;
/** /**
* The LocalDataSource class for Places * The LocalDataSource class for Places
@ -26,6 +30,81 @@ public class PlacesLocalDataSource {
return placeDao.getPlace(entityID); return placeDao.getPlace(entityID);
} }
/**
* Retrieves a list of places from the database within the geographical area
* specified by map's opposite corners.
*
* @param mapBottomLeft Bottom left corner of the map.
* @param mapTopRight Top right corner of the map.
* @return The list of saved places within the map's view.
*/
public List<Place> fetchPlaces(final LatLng mapBottomLeft, final LatLng mapTopRight) {
class Constraint {
final double latBegin;
final double lngBegin;
final double latEnd;
final double lngEnd;
public Constraint(final double latBegin, final double lngBegin, final double latEnd,
final double lngEnd) {
this.latBegin = latBegin;
this.lngBegin = lngBegin;
this.latEnd = latEnd;
this.lngEnd = lngEnd;
}
}
final List<Constraint> constraints = new ArrayList<>();
if (mapTopRight.getLatitude() < mapBottomLeft.getLatitude()) {
if (mapTopRight.getLongitude() < mapBottomLeft.getLongitude()) {
constraints.add(
new Constraint(mapBottomLeft.getLatitude(), mapBottomLeft.getLongitude(), 90.0,
180.0));
constraints.add(new Constraint(mapBottomLeft.getLatitude(), -180.0, 90.0,
mapTopRight.getLongitude()));
constraints.add(
new Constraint(-90.0, mapBottomLeft.getLongitude(), mapTopRight.getLatitude(),
180.0));
constraints.add(new Constraint(-90.0, -180.0, mapTopRight.getLatitude(),
mapTopRight.getLongitude()));
} else {
constraints.add(
new Constraint(mapBottomLeft.getLatitude(), mapBottomLeft.getLongitude(), 90.0,
mapTopRight.getLongitude()));
constraints.add(
new Constraint(-90.0, mapBottomLeft.getLongitude(), mapTopRight.getLatitude(),
mapTopRight.getLongitude()));
}
} else {
if (mapTopRight.getLongitude() < mapBottomLeft.getLongitude()) {
constraints.add(
new Constraint(mapBottomLeft.getLatitude(), mapBottomLeft.getLongitude(),
mapTopRight.getLatitude(), 180.0));
constraints.add(
new Constraint(mapBottomLeft.getLatitude(), -180.0, mapTopRight.getLatitude(),
mapTopRight.getLongitude()));
} else {
constraints.add(
new Constraint(mapBottomLeft.getLatitude(), mapBottomLeft.getLongitude(),
mapTopRight.getLatitude(), mapTopRight.getLongitude()));
}
}
final List<Place> cachedPlaces = new ArrayList<>();
for (final Constraint constraint : constraints) {
cachedPlaces.addAll(placeDao.fetchPlaces(
constraint.latBegin,
constraint.lngBegin,
constraint.latEnd,
constraint.lngEnd
));
}
return cachedPlaces;
}
/** /**
* Saves a Place object asynchronously into the database. * Saves a Place object asynchronously into the database.
* *

View file

@ -4,6 +4,7 @@ import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LatLng;
import io.reactivex.Completable; import io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import java.util.List;
import javax.inject.Inject; import javax.inject.Inject;
/** /**
@ -39,6 +40,17 @@ public class PlacesRepository {
return localDataSource.fetchPlace(entityID); return localDataSource.fetchPlace(entityID);
} }
/**
* Retrieves a list of places within the geographical area specified by map's opposite corners.
*
* @param mapBottomLeft Bottom left corner of the map.
* @param mapTopRight Top right corner of the map.
* @return The list of saved places within the map's view.
*/
public List<Place> fetchPlaces(final LatLng mapBottomLeft, final LatLng mapTopRight) {
return localDataSource.fetchPlaces(mapBottomLeft, mapTopRight);
}
/** /**
* Clears the Nearby cache on an IO thread. * Clears the Nearby cache on an IO thread.
* *

View file

@ -94,6 +94,7 @@ class WikidataFeedback : BaseActivity() {
}, { throwable: Throwable? -> }, { throwable: Throwable? ->
Timber.e(throwable!!) Timber.e(throwable!!)
}) })
finish()
} }
} }
} }

View file

@ -2,11 +2,13 @@ package fr.free.nrw.commons.nearby.contract;
import android.content.Context; import android.content.Context;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleCoroutineScope;
import fr.free.nrw.commons.BaseMarker; import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng; import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType; import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType;
import fr.free.nrw.commons.nearby.Label; import fr.free.nrw.commons.nearby.Label;
import fr.free.nrw.commons.nearby.MarkerPlaceGroup;
import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.nearby.Place;
import java.util.List; import java.util.List;
@ -16,6 +18,8 @@ public interface NearbyParentFragmentContract {
boolean isNetworkConnectionEstablished(); boolean isNetworkConnectionEstablished();
void updateSnackbar(boolean offlinePinsShown);
void listOptionMenuItemClicked(); void listOptionMenuItemClicked();
void populatePlaces(LatLng currentLatLng); void populatePlaces(LatLng currentLatLng);
@ -68,7 +72,7 @@ public interface NearbyParentFragmentContract {
Context getContext(); Context getContext();
void updateMapMarkers(List<BaseMarker> BaseMarkers); void replaceMarkerOverlays(List<MarkerPlaceGroup> markerPlaceGroups);
void filterOutAllMarkers(); void filterOutAllMarkers();
@ -89,6 +93,10 @@ public interface NearbyParentFragmentContract {
LatLng getMapFocus(); LatLng getMapFocus();
LatLng getScreenTopRight();
LatLng getScreenBottomLeft();
boolean isAdvancedQueryFragmentVisible(); boolean isAdvancedQueryFragmentVisible();
void showHideAdvancedQueryFragment(boolean shouldShow); void showHideAdvancedQueryFragment(boolean shouldShow);
@ -120,12 +128,14 @@ public interface NearbyParentFragmentContract {
void filterByMarkerType(List<Label> selectedLabels, int state, boolean filterForPlaceState, void filterByMarkerType(List<Label> selectedLabels, int state, boolean filterForPlaceState,
boolean filterForAllNoneType); boolean filterForAllNoneType);
void updateMapMarkersToController(List<BaseMarker> baseMarkers);
void searchViewGainedFocus(); void searchViewGainedFocus();
void setCheckboxUnknown(); void setCheckboxUnknown();
void setAdvancedQuery(String query); void setAdvancedQuery(String query);
void toggleBookmarkedStatus(Place place);
void handleMapScrolled(LifecycleCoroutineScope scope, boolean isNetworkAvailable);
} }
} }

View file

@ -128,6 +128,8 @@ class CommonPlaceClickActions
AlertDialog AlertDialog
.Builder(activity) .Builder(activity)
.setMessage(R.string.login_alert_message) .setMessage(R.string.login_alert_message)
.setCancelable(false)
.setNegativeButton(R.string.cancel){_,_ -> }
.setPositiveButton(R.string.login) { dialog, which -> .setPositiveButton(R.string.login) { dialog, which ->
setPositiveButton() setPositiveButton()
}.show() }.show()

View file

@ -23,8 +23,7 @@ import android.graphics.drawable.Drawable;
import android.location.Location; import android.location.Location;
import android.location.LocationManager; import android.location.LocationManager;
import android.net.Uri; import android.net.Uri;
import android.os.Build.VERSION; import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.os.Handler; import android.os.Handler;
@ -56,6 +55,8 @@ import androidx.appcompat.app.AlertDialog.Builder;
import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider; import androidx.core.content.FileProvider;
import androidx.lifecycle.LifecycleCoroutineScope;
import androidx.lifecycle.LifecycleOwnerKt;
import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
@ -64,7 +65,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.jakewharton.rxbinding2.view.RxView; import com.jakewharton.rxbinding2.view.RxView;
import com.jakewharton.rxbinding3.appcompat.RxSearchView; import com.jakewharton.rxbinding3.appcompat.RxSearchView;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.CommonsApplication.BaseLogoutListener; import fr.free.nrw.commons.CommonsApplication.BaseLogoutListener;
import fr.free.nrw.commons.MapController.NearbyPlacesInfo; import fr.free.nrw.commons.MapController.NearbyPlacesInfo;
@ -92,6 +92,7 @@ import fr.free.nrw.commons.nearby.NearbyFilterSearchRecyclerViewAdapter;
import fr.free.nrw.commons.nearby.NearbyFilterState; import fr.free.nrw.commons.nearby.NearbyFilterState;
import fr.free.nrw.commons.nearby.Place; import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.PlacesRepository; import fr.free.nrw.commons.nearby.PlacesRepository;
import fr.free.nrw.commons.nearby.Sitelinks;
import fr.free.nrw.commons.nearby.WikidataFeedback; import fr.free.nrw.commons.nearby.WikidataFeedback;
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract; import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract;
import fr.free.nrw.commons.nearby.fragments.AdvanceQueryFragment.Callback; import fr.free.nrw.commons.nearby.fragments.AdvanceQueryFragment.Callback;
@ -110,16 +111,12 @@ import fr.free.nrw.commons.wikidata.WikidataEditListener;
import io.reactivex.Completable; import io.reactivex.Completable;
import io.reactivex.Observable; import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -155,6 +152,29 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
FragmentNearbyParentBinding binding; FragmentNearbyParentBinding binding;
public final MapEventsOverlay mapEventsOverlay = new MapEventsOverlay(new MapEventsReceiver() {
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
if (clickedMarker != null) {
clickedMarker.closeInfoWindow();
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
hideBottomSheet();
} else if (isDetailsBottomSheetVisible()) {
hideBottomDetailsSheet();
}
return true;
}
@Override
public boolean longPressHelper(GeoPoint p) {
return false;
}
});
@Inject @Inject
LocationServiceManager locationManager; LocationServiceManager locationManager;
@Inject @Inject
@ -189,6 +209,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
private boolean isNetworkErrorOccurred; private boolean isNetworkErrorOccurred;
private Snackbar snackbar; private Snackbar snackbar;
private View view; private View view;
private LifecycleCoroutineScope scope;
private NearbyParentFragmentPresenter presenter; private NearbyParentFragmentPresenter presenter;
private boolean isDarkTheme; private boolean isDarkTheme;
private boolean isFABsExpanded; private boolean isFABsExpanded;
@ -212,12 +233,9 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
private Place nearestPlace; private Place nearestPlace;
private volatile boolean stopQuery; private volatile boolean stopQuery;
private boolean isSearchInProgress = false;
private final Handler searchHandler = new Handler(); private final Handler searchHandler = new Handler();
private Runnable searchRunnable; private Runnable searchRunnable;
private static final long SCROLL_DELAY = 800; // Delay for debounce of onscroll, in milliseconds.
private List<Place> updatedPlacesList;
private LatLng updatedLatLng; private LatLng updatedLatLng;
private boolean searchable; private boolean searchable;
@ -308,10 +326,6 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
* WLM URL * WLM URL
*/ */
public static final String WLM_URL = "https://commons.wikimedia.org/wiki/Commons:Mobile_app/Contributing_to_WLM_using_the_app"; public static final String WLM_URL = "https://commons.wikimedia.org/wiki/Commons:Mobile_app/Contributing_to_WLM_using_the_app";
/**
* Saves response of list of places for the first time
*/
private List<Place> places = new ArrayList<>();
@NonNull @NonNull
public static NearbyParentFragment newInstance() { public static NearbyParentFragment newInstance() {
@ -327,7 +341,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
view = binding.getRoot(); view = binding.getRoot();
initNetworkBroadCastReceiver(); initNetworkBroadCastReceiver();
presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao); scope = LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner());
presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, nearbyController);
progressDialog = new ProgressDialog(getActivity()); progressDialog = new ProgressDialog(getActivity());
progressDialog.setCancelable(false); progressDialog.setCancelable(false);
progressDialog.setMessage("Saving in progress..."); progressDialog.setMessage("Saving in progress...");
@ -452,54 +467,12 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
binding.map.getOverlays().add(scaleBarOverlay); binding.map.getOverlays().add(scaleBarOverlay);
binding.map.getZoomController().setVisibility(Visibility.NEVER); binding.map.getZoomController().setVisibility(Visibility.NEVER);
binding.map.getController().setZoom(ZOOM_LEVEL); binding.map.getController().setZoom(ZOOM_LEVEL);
binding.map.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { binding.map.getOverlays().add(mapEventsOverlay);
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
if (clickedMarker != null) {
clickedMarker.closeInfoWindow();
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
hideBottomSheet();
} else if (isDetailsBottomSheetVisible()) {
hideBottomDetailsSheet();
}
return true;
}
@Override
public boolean longPressHelper(GeoPoint p) {
return false;
}
}));
binding.map.addMapListener(new MapListener() { binding.map.addMapListener(new MapListener() {
@Override @Override
public boolean onScroll(ScrollEvent event) { public boolean onScroll(ScrollEvent event) {
presenter.handleMapScrolled(scope, !isNetworkErrorOccurred);
// Remove any pending search runnables
searchHandler.removeCallbacks(searchRunnable);
// Set a runnable to call the Search after a delay
searchRunnable = new Runnable() {
@Override
public void run() {
if (!isSearchInProgress) {
isSearchInProgress = true; // search executing flag
// Start Search
try {
presenter.searchInTheArea();
} finally {
isSearchInProgress = false;
}
}
}
};
// post runnable with configured SCROLL_DELAY
searchHandler.postDelayed(searchRunnable, SCROLL_DELAY);
return true; return true;
} }
@ -519,7 +492,12 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
moveCameraToPosition(lastMapFocus); moveCameraToPosition(lastMapFocus);
initRvNearbyList(); initRvNearbyList();
onResume(); onResume();
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution))); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution), Html.FROM_HTML_MODE_LEGACY));
} else {
//noinspection deprecation
binding.tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
}
binding.tvAttribution.setMovementMethod(LinkMovementMethod.getInstance()); binding.tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
binding.nearbyFilterList.btnAdvancedOptions.setOnClickListener(v -> { binding.nearbyFilterList.btnAdvancedOptions.setOnClickListener(v -> {
binding.nearbyFilter.searchViewLayout.searchView.clearFocus(); binding.nearbyFilter.searchViewLayout.searchView.clearFocus();
@ -605,8 +583,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
return Unit.INSTANCE; return Unit.INSTANCE;
}, },
(place, isBookmarked) -> { (place, isBookmarked) -> {
updateMarker(isBookmarked, place, null); presenter.toggleBookmarkedStatus(place);
binding.map.invalidate();
return Unit.INSTANCE; return Unit.INSTANCE;
}, },
commonPlaceClickActions, commonPlaceClickActions,
@ -670,19 +647,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
registerNetworkReceiver(); registerNetworkReceiver();
if (isResumed() && ((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) { if (isResumed() && ((MainActivity) getActivity()).activeFragment == ActiveFragment.NEARBY) {
if (locationPermissionsHelper.checkLocationPermission(getActivity())) { if (locationPermissionsHelper.checkLocationPermission(getActivity())) {
if (lastFocusLocation == null && lastKnownLocation == null) { locationPermissionGranted();
locationPermissionGranted();
} else{
if (updatedPlacesList != null) {
if (!updatedPlacesList.isEmpty()) {
loadPlacesDataAsync(updatedPlacesList, updatedLatLng);
} else {
updateMapMarkers(updatedPlacesList, getLastMapFocus(), false);
}
}else {
locationPermissionGranted();
}
}
} else { } else {
startMapWithoutPermission(); startMapWithoutPermission();
} }
@ -973,7 +938,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
@Override @Override
public void updateListFragment(final List<Place> placeList) { public void updateListFragment(final List<Place> placeList) {
places = placeList; adapter.clear();
adapter.setItems(placeList); adapter.setItems(placeList);
binding.bottomSheetNearby.noResultsMessage.setVisibility( binding.bottomSheetNearby.noResultsMessage.setVisibility(
placeList.isEmpty() ? View.VISIBLE : View.GONE); placeList.isEmpty() ? View.VISIBLE : View.GONE);
@ -1075,6 +1040,23 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
}; };
} }
/**
* Updates the internet unavailable snackbar to reflect whether cached pins are shown.
*
* @param offlinePinsShown Whether there are pins currently being shown on map.
*/
@Override
public void updateSnackbar(final boolean offlinePinsShown) {
if (!isNetworkErrorOccurred || snackbar == null) {
return;
}
if (offlinePinsShown) {
snackbar.setText(R.string.nearby_showing_pins_offline);
} else {
snackbar.setText(R.string.no_internet);
}
}
/** /**
* Hide or expand bottom sheet according to states of all sheets * Hide or expand bottom sheet according to states of all sheets
*/ */
@ -1089,16 +1071,38 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
} }
} }
/**
* Returns the location of the top right corner of the map view.
*
* @return a `LatLng` object denoting the location of the top right corner of the map.
*/
@Override
public LatLng getScreenTopRight() {
final IGeoPoint screenTopRight = binding.map.getProjection()
.fromPixels(binding.map.getWidth(), 0);
return new LatLng(
screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0);
}
/**
* Returns the location of the bottom left corner of the map view.
*
* @return a `LatLng` object denoting the location of the bottom left corner of the map.
*/
@Override
public LatLng getScreenBottomLeft() {
final IGeoPoint screenBottomLeft = binding.map.getProjection()
.fromPixels(0, binding.map.getHeight());
return new LatLng(
screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0);
}
@Override @Override
public void populatePlaces(final LatLng currentLatLng) { public void populatePlaces(final LatLng currentLatLng) {
IGeoPoint screenTopRight = binding.map.getProjection() // these two variables have historically been assigned values the opposite of what their
.fromPixels(binding.map.getWidth(), 0); // names imply, and quite some existing code depends on this fact
IGeoPoint screenBottomLeft = binding.map.getProjection() LatLng screenTopRightLatLng = getScreenBottomLeft();
.fromPixels(0, binding.map.getHeight()); LatLng screenBottomLeftLatLng = getScreenTopRight();
LatLng screenTopRightLatLng = new LatLng(
screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0);
LatLng screenBottomLeftLatLng = new LatLng(
screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0);
// When the nearby fragment is opened immediately upon app launch, the {screenTopRightLatLng} // When the nearby fragment is opened immediately upon app launch, the {screenTopRightLatLng}
// and {screenBottomLeftLatLng} variables return {LatLng(0.0,0.0)} as output. // and {screenBottomLeftLatLng} variables return {LatLng(0.0,0.0)} as output.
@ -1122,19 +1126,19 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
eastCornerLong, 0); eastCornerLong, 0);
if (currentLatLng.equals( if (currentLatLng.equals(
getLastMapFocus())) { // Means we are checking around current location getLastMapFocus())) { // Means we are checking around current location
populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} else { } else {
populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} }
} else { } else {
if (currentLatLng.equals( if (currentLatLng.equals(
getLastMapFocus())) { // Means we are checking around current location getLastMapFocus())) { // Means we are checking around current location
populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} else { } else {
populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} }
} }
@ -1151,14 +1155,10 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
populatePlaces(currentLatLng); populatePlaces(currentLatLng);
return; return;
} }
IGeoPoint screenTopRight = binding.map.getProjection() // these two variables have historically been assigned values the opposite of what their
.fromPixels(binding.map.getWidth(), 0); // names imply, and quite some existing code depends on this fact
IGeoPoint screenBottomLeft = binding.map.getProjection() final LatLng screenTopRightLatLng = getScreenBottomLeft();
.fromPixels(0, binding.map.getHeight()); final LatLng screenBottomLeftLatLng = getScreenTopRight();
LatLng screenTopRightLatLng = new LatLng(
screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0);
LatLng screenBottomLeftLatLng = new LatLng(
screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0);
if (currentLatLng.equals(lastFocusLocation) || lastFocusLocation == null if (currentLatLng.equals(lastFocusLocation) || lastFocusLocation == null
|| recenterToUserLocation) { // Means we are checking around current location || recenterToUserLocation) { // Means we are checking around current location
@ -1174,27 +1174,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
} }
/** /**
* Reloads the Nearby map * Clears the Nearby local cache and then calls for pin details to be fetched afresh.
* Clears all location markers, refreshes them, reinserts them into the map.
*
*/
private void reloadMap() {
clearAllMarkers(); // Clear the list of markers
binding.map.getController().setZoom(ZOOM_LEVEL); // Reset the zoom level
binding.map.getController().setCenter(lastMapFocus); // Recenter the focus
if (locationPermissionsHelper.checkLocationPermission(getActivity())) {
locationPermissionGranted(); // Reload map with user's location
} else {
startMapWithoutPermission(); // Reload map without user's location
}
binding.map.invalidate(); // Invalidate the map
presenter.updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED); // Restart the map
Timber.d("Reloaded Map Successfully");
}
/**
* Clears the Nearby local cache and then calls for the map to be reloaded
* *
*/ */
private void emptyCache() { private void emptyCache() {
@ -1203,7 +1183,22 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
placesRepository.clearCache() placesRepository.clearCache()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.andThen(Completable.fromAction(this::reloadMap)) .andThen(Completable.fromAction(() -> {
// reload only the pin details, by making all loaded pins gray:
ArrayList<MarkerPlaceGroup> newPlaceGroups = new ArrayList<>(
NearbyController.markerLabelList.size());
for (final MarkerPlaceGroup placeGroup : NearbyController.markerLabelList) {
final Place place = new Place("", "", placeGroup.getPlace().getLabel(), "",
placeGroup.getPlace().getLocation(), "",
placeGroup.getPlace().siteLinks, "", placeGroup.getPlace().exists,
placeGroup.getPlace().entityID);
place.setDistance(placeGroup.getPlace().distance);
place.setMonument(placeGroup.getPlace().isMonument());
newPlaceGroups.add(
new MarkerPlaceGroup(placeGroup.getIsBookmarked(), place));
}
presenter.loadPlacesDataAsync(newPlaceGroups, scope);
}))
.subscribe( .subscribe(
() -> { () -> {
Timber.d("Nearby Cache cleared successfully."); Timber.d("Nearby Cache cleared successfully.");
@ -1367,13 +1362,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
? getTextBetweenParentheses( ? getTextBetweenParentheses(
updatedPlace.getLongDescription()) : updatedPlace.getLongDescription()); updatedPlace.getLongDescription()) : updatedPlace.getLongDescription());
marker.showInfoWindow(); marker.showInfoWindow();
for (int i = 0; i < updatedPlacesList.size(); i++) { presenter.handlePinClicked(updatedPlace);
Place pl = updatedPlacesList.get(i); savePlaceToDatabase(place);
if (pl.location == updatedPlace.location) {
updatedPlacesList.set(i, updatedPlace);
savePlaceToDatabase(place);
}
}
Drawable icon = ContextCompat.getDrawable(getContext(), Drawable icon = ContextCompat.getDrawable(getContext(),
getIconFor(updatedPlace, isBookMarked)); getIconFor(updatedPlace, isBookMarked));
marker.setIcon(icon); marker.setIcon(icon);
@ -1412,12 +1402,10 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
setProgressBarVisibility(false); setProgressBarVisibility(false);
presenter.lockUnlockNearby(false); presenter.lockUnlockNearby(false);
} else { } else {
updateMapMarkers(nearbyPlacesInfo.placeList, nearbyPlacesInfo.currentLatLng, updateMapMarkers(nearbyPlacesInfo.placeList, searchLatLng, true);
true);
lastFocusLocation = searchLatLng; lastFocusLocation = searchLatLng;
lastMapFocus = new GeoPoint(searchLatLng.getLatitude(), lastMapFocus = new GeoPoint(searchLatLng.getLatitude(),
searchLatLng.getLongitude()); searchLatLng.getLongitude());
loadPlacesDataAsync(nearbyPlacesInfo.placeList, nearbyPlacesInfo.currentLatLng);
} }
}, },
throwable -> { throwable -> {
@ -1457,12 +1445,10 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
// curLatLng is used to calculate distance from the current location to the place // curLatLng is used to calculate distance from the current location to the place
// and distance is later on populated to the place // and distance is later on populated to the place
updateMapMarkers(nearbyPlacesInfo.placeList, nearbyPlacesInfo.currentLatLng, updateMapMarkers(nearbyPlacesInfo.placeList, searchLatLng, false);
false);
lastMapFocus = new GeoPoint(searchLatLng.getLatitude(), lastMapFocus = new GeoPoint(searchLatLng.getLatitude(),
searchLatLng.getLongitude()); searchLatLng.getLongitude());
stopQuery(); stopQuery();
loadPlacesDataAsync(nearbyPlacesInfo.placeList, nearbyPlacesInfo.currentLatLng);
} }
}, },
throwable -> { throwable -> {
@ -1475,167 +1461,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
})); }));
} }
public void loadPlacesDataAsync(List<Place> placeList, LatLng curLatLng) { public void savePlaceToDatabase(Place place) {
List<Place> places = new ArrayList<>(placeList);
// Instead of loading all pins in a single SPARQL query, we query in batches.
// This variable controls the number of pins queried per batch.
int batchSize = 3;
updatedLatLng = curLatLng;
updatedPlacesList = new ArrayList<>(placeList);
// Sorts the places by distance to ensure the nearest pins are ready for the user as soon
// as possible.
if (VERSION.SDK_INT >= VERSION_CODES.N) {
Collections.sort(places,
Comparator.comparingDouble(place -> place.getDistanceInDouble(getMapFocus())));
}
stopQuery = false;
processBatchesSequentially(places, batchSize, updatedPlacesList, curLatLng, 0);
}
/**
* Processes a list of places in batches sequentially. This method handles the asynchronous
* processing of places, updating the map markers and updates the list of updated places accordingly.
*
* @param places The list of Place objects to be processed.
* @param batchSize The size of each batch to be processed.
* @param updatedPlaceList The list of Place objects to be updated.
* @param curLatLng The current location of the user.
* @param startIndex The starting index for the current batch.
*/
@SuppressLint("CheckResult")
private void processBatchesSequentially(List<Place> places, int batchSize,
List<Place> updatedPlaceList, LatLng curLatLng, int startIndex) {
if (startIndex >= places.size() || stopQuery) {
return;
}
int endIndex = Math.min(startIndex + batchSize, places.size());
List<Place> batch = places.subList(startIndex, endIndex);
for (int i = 0; i < batch.size(); i++) {
if (i == batch.size() - 1 && batch.get(i).name != "") {
processBatchesSequentially(places, batchSize, updatedPlaceList, curLatLng,
endIndex + batchSize);
return;
}
if (batch.get(i).name == "") {
if (i == 0) {
break;
}
processBatchesSequentially(places, batchSize, updatedPlaceList, curLatLng,
endIndex + i);
return;
}
}
Disposable disposable = processBatch(batch, updatedPlaceList)
.subscribe(p -> {
if (stopQuery) {
return;
}
if (!p.isEmpty() && p != updatedPlaceList) {
synchronized (updatedPlaceList) {
updatedPlaceList.clear();
updatedPlaceList.addAll((Collection<? extends Place>) p);
}
}
updateMapMarkers(new ArrayList<>(updatedPlaceList), curLatLng, false);
processBatchesSequentially(places, batchSize, updatedPlaceList, curLatLng, endIndex);
}, throwable -> {
Timber.e(throwable);
showErrorMessage(getString(R.string.error_fetching_nearby_places) + throwable.getLocalizedMessage());
setFilterState();
});
compositeDisposable.add(disposable);
}
/**
* Processes a batch of places, updating the provided place list with fetched or updated data.
* This method handles the asynchronous fetching and updating of places from the repository.
*
* @param batch The batch of Place objects to be processed.
* @param placeList The list of Place objects to be updated.
* @return An Observable emitting the updated list of Place objects.
*/
private Observable<List<?>> processBatch(List<Place> batch, List<Place> placeList) {
List<Place> toBeProcessed = new ArrayList<>();
List<Observable<Place>> placeObservables = new ArrayList<>();
for (Place place : batch) {
Observable<Place> placeObservable = Observable
.fromCallable(() -> {
Place fetchedPlace = placesRepository.fetchPlace(place.entityID);
return fetchedPlace != null ? fetchedPlace : place;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(placeData -> {
if (placeData.equals(place)) {
toBeProcessed.add(place);
} else {
for (int i = 0; i < placeList.size(); i++) {
Place pl = placeList.get(i);
if (pl.location.equals(place.location)) {
placeList.set(i, placeData);
break;
}
}
}
});
placeObservables.add(placeObservable);
}
return Observable.zip(placeObservables, objects -> toBeProcessed)
.flatMap(processedList -> {
if (processedList.isEmpty()) {
return Observable.just(placeList);
}
return Observable.fromCallable(() -> nearbyController.getPlaces(processedList))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map(places -> {
if (stopQuery) {
return Collections.emptyList();
}
if (places == null || places.isEmpty()) {
return Collections.emptyList();
} else {
List<Place> updatedPlaceList = new ArrayList<>(placeList);
for (Place place : places) {
for (Place foundPlace : placeList) {
if (place.siteLinks.getWikidataLink()
.equals(foundPlace.siteLinks.getWikidataLink())) {
place.location = foundPlace.location;
place.distance = foundPlace.distance;
place.setMonument(foundPlace.isMonument());
int index = updatedPlaceList.indexOf(foundPlace);
if (index != -1) {
updatedPlaceList.set(index, place);
savePlaceToDatabase(place);
}
break;
}
}
}
return updatedPlaceList;
}
})
.onErrorReturn(throwable -> {
Timber.e(throwable);
showErrorMessage(getString(R.string.error_fetching_nearby_places) + " "
+ throwable.getLocalizedMessage());
setFilterState();
return Collections.emptyList();
});
});
}
private void savePlaceToDatabase(Place place) {
compositeDisposable.add(placesRepository compositeDisposable.add(placesRepository
.save(place) .save(place)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@ -1661,8 +1487,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
*/ */
private void updateMapMarkers(final List<Place> nearbyPlaces, final LatLng curLatLng, private void updateMapMarkers(final List<Place> nearbyPlaces, final LatLng curLatLng,
final boolean shouldUpdateSelectedMarker) { final boolean shouldUpdateSelectedMarker) {
presenter.updateMapMarkers(nearbyPlaces, curLatLng, shouldUpdateSelectedMarker); presenter.updateMapMarkers(nearbyPlaces, curLatLng, scope);
setFilterState();
} }
@ -1798,6 +1623,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
// prompt the user to login // prompt the user to login
new Builder(getContext()) new Builder(getContext())
.setMessage(R.string.login_alert_message) .setMessage(R.string.login_alert_message)
.setCancelable(false)
.setNegativeButton(R.string.cancel, (dialog, which) -> {})
.setPositiveButton(R.string.login, (dialog, which) -> { .setPositiveButton(R.string.login, (dialog, which) -> {
// logout of the app // logout of the app
BaseLogoutListener logoutListener = new BaseLogoutListener(getActivity()); BaseLogoutListener logoutListener = new BaseLogoutListener(getActivity());
@ -1899,13 +1726,6 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
} }
} }
@Override
public void updateMapMarkers(final List<BaseMarker> BaseMarkers) {
if (binding.map != null) {
presenter.updateMapMarkersToController(BaseMarkers);
}
}
@Override @Override
public void filterOutAllMarkers() { public void filterOutAllMarkers() {
clearAllMarkers(); clearAllMarkers();
@ -1925,8 +1745,11 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
final boolean displayExists = false; final boolean displayExists = false;
final boolean displayNeedsPhoto= false; final boolean displayNeedsPhoto= false;
final boolean displayWlm = false; final boolean displayWlm = false;
// Remove the previous markers before updating them if (selectedLabels == null || selectedLabels.size() == 0) {
clearAllMarkers(); replaceMarkerOverlays(NearbyController.markerLabelList);
return;
}
final ArrayList<MarkerPlaceGroup> placeGroupsToShow = new ArrayList<>();
for (final MarkerPlaceGroup markerPlaceGroup : NearbyController.markerLabelList) { for (final MarkerPlaceGroup markerPlaceGroup : NearbyController.markerLabelList) {
final Place place = markerPlaceGroup.getPlace(); final Place place = markerPlaceGroup.getPlace();
// When label filter is engaged // When label filter is engaged
@ -1967,19 +1790,12 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
} }
if (shouldUpdateMarker) { if (shouldUpdateMarker) {
updateMarker(markerPlaceGroup.getIsBookmarked(), place, placeGroupsToShow.add(
NearbyController.currentLocation); new MarkerPlaceGroup(markerPlaceGroup.getIsBookmarked(), place)
);
} }
} }
if (selectedLabels == null || selectedLabels.size() == 0) { replaceMarkerOverlays(placeGroupsToShow);
ArrayList<BaseMarker> markerArrayList = new ArrayList<>();
for (final MarkerPlaceGroup markerPlaceGroup : NearbyController.markerLabelList) {
BaseMarker nearbyBaseMarker = new BaseMarker();
nearbyBaseMarker.setPlace(markerPlaceGroup.getPlace());
markerArrayList.add(nearbyBaseMarker);
}
addMarkersToMap(markerArrayList);
}
} }
@Override @Override
@ -1987,18 +1803,6 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
return binding.map == null ? null : getMapFocus(); return binding.map == null ? null : getMapFocus();
} }
/**
* Sets marker icon according to marker status. Sets title and distance.
*
* @param isBookmarked true if place is bookmarked
* @param place
* @param currentLatLng current location
*/
public void updateMarker(final boolean isBookmarked, final Place place,
@Nullable final LatLng currentLatLng) {
addMarkerToMap(place, isBookmarked);
}
/** /**
* Highlights nearest place when user clicks on home nearby banner * Highlights nearest place when user clicks on home nearby banner
* *
@ -2052,13 +1856,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
); );
} }
/** public Marker convertToMarker(Place place, boolean isBookMarked) {
* Adds a marker representing a place to the map with optional bookmark icon.
*
* @param place The Place object containing information about the location.
* @param isBookMarked A Boolean flag indicating whether the place is bookmarked or not.
*/
private void addMarkerToMap(Place place, Boolean isBookMarked) {
Drawable icon = ContextCompat.getDrawable(getContext(), getIconFor(place, isBookMarked)); Drawable icon = ContextCompat.getDrawable(getContext(), getIconFor(place, isBookMarked));
GeoPoint point = new GeoPoint(place.location.getLatitude(), place.location.getLongitude()); GeoPoint point = new GeoPoint(place.location.getLatitude(), place.location.getLongitude());
Marker marker = new Marker(binding.map); Marker marker = new Marker(binding.map);
@ -2072,44 +1870,57 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
place.getLongDescription()) : place.getLongDescription()); place.getLongDescription()) : place.getLongDescription());
} }
marker.setTextLabelFontSize(40); marker.setTextLabelFontSize(40);
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_TOP); // anchorV is 21.707/28.0 as icon height is 28dp while the pin base is at 21.707dp from top
marker.setAnchor(Marker.ANCHOR_CENTER, 0.77525f);
marker.setOnMarkerClickListener((marker1, mapView) -> { marker.setOnMarkerClickListener((marker1, mapView) -> {
if (clickedMarker != null) { if (clickedMarker != null) {
clickedMarker.closeInfoWindow(); clickedMarker.closeInfoWindow();
} }
clickedMarker = marker1; clickedMarker = marker1;
binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.VISIBLE); if (!isNetworkErrorOccurred) {
binding.bottomSheetDetails.icon.setVisibility(View.GONE); binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.VISIBLE);
binding.bottomSheetDetails.wikiDataLl.setVisibility(View.GONE); binding.bottomSheetDetails.icon.setVisibility(View.GONE);
if (Objects.equals(place.name, "")) { binding.bottomSheetDetails.wikiDataLl.setVisibility(View.GONE);
getPlaceData(place.getWikiDataEntityId(), place, marker1, isBookMarked); if (Objects.equals(place.name, "")) {
getPlaceData(place.getWikiDataEntityId(), place, marker1, isBookMarked);
} else {
marker.showInfoWindow();
binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.GONE);
binding.bottomSheetDetails.icon.setVisibility(View.VISIBLE);
binding.bottomSheetDetails.wikiDataLl.setVisibility(View.VISIBLE);
passInfoToSheet(place);
hideBottomSheet();
}
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else { } else {
marker.showInfoWindow(); marker.showInfoWindow();
binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.GONE);
binding.bottomSheetDetails.icon.setVisibility(View.VISIBLE);
binding.bottomSheetDetails.wikiDataLl.setVisibility(View.VISIBLE);
passInfoToSheet(place);
hideBottomSheet();
} }
bottomSheetDetailsBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
return true; return true;
}); });
binding.map.getOverlays().add(marker); return marker;
} }
/** /**
* Adds multiple markers representing places to the map and handles item gestures. * Adds multiple markers representing places to the map and handles item gestures.
* *
* @param nearbyBaseMarkers The list of Place objects containing information about the * @param markerPlaceGroups The list of marker place groups containing the places and
* locations. * their bookmarked status
*/ */
private void addMarkersToMap(List<BaseMarker> nearbyBaseMarkers) { @Override
public void replaceMarkerOverlays(final List<MarkerPlaceGroup> markerPlaceGroups) {
for(int i = 0; i< nearbyBaseMarkers.size(); i++){ ArrayList<Marker> newMarkers = new ArrayList<>(markerPlaceGroups.size());
addMarkerToMap(nearbyBaseMarkers.get(i).getPlace(), false); // iterate in reverse so that the nearest pins get rendered on top
for (int i = markerPlaceGroups.size() - 1; i >= 0; i--) {
newMarkers.add(
convertToMarker(markerPlaceGroups.get(i).getPlace(),
markerPlaceGroups.get(i).getIsBookmarked())
);
} }
clearAllMarkers();
binding.map.getOverlays().addAll(newMarkers);
} }
/** /**
* Extracts text between the first occurrence of '(' and its corresponding ')' in the input * Extracts text between the first occurrence of '(' and its corresponding ')' in the input
* string. * string.
@ -2400,7 +2211,6 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
binding.map.invalidate(); binding.map.invalidate();
GeoPoint geoPoint = mapCenter; GeoPoint geoPoint = mapCenter;
if (geoPoint != null) { if (geoPoint != null) {
List<Overlay> overlays = binding.map.getOverlays();
ScaleDiskOverlay diskOverlay = ScaleDiskOverlay diskOverlay =
new ScaleDiskOverlay(this.getContext(), new ScaleDiskOverlay(this.getContext(),
geoPoint, 2000, UnitOfMeasure.foot); geoPoint, 2000, UnitOfMeasure.foot);
@ -2434,28 +2244,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
scaleBarOverlay.setBackgroundPaint(barPaint); scaleBarOverlay.setBackgroundPaint(barPaint);
scaleBarOverlay.enableScaleBar(); scaleBarOverlay.enableScaleBar();
binding.map.getOverlays().add(scaleBarOverlay); binding.map.getOverlays().add(scaleBarOverlay);
binding.map.getOverlays().add(new MapEventsOverlay(new MapEventsReceiver() { binding.map.getOverlays().add(mapEventsOverlay);
@Override
public boolean singleTapConfirmedHelper(GeoPoint p) {
if (clickedMarker != null) {
clickedMarker.closeInfoWindow();
} else {
Timber.e("CLICKED MARKER IS NULL");
}
if (isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
hideBottomSheet();
} else if (isDetailsBottomSheetVisible()) {
hideBottomDetailsSheet();
}
return true;
}
@Override
public boolean longPressHelper(GeoPoint p) {
return false;
}
}));
binding.map.setMultiTouchControls(true); binding.map.setMultiTouchControls(true);
} }
@ -2510,21 +2299,14 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
@Override @Override
public void onBottomSheetItemClick(@Nullable View view, int position) { public void onBottomSheetItemClick(@Nullable View view, int position) {
BottomSheetItem item = dataList.get(position); BottomSheetItem item = dataList.get(position);
boolean isBookmarked = bookmarkLocationDao.findBookmarkLocation(selectedPlace);
switch (item.getImageResourceId()) { switch (item.getImageResourceId()) {
case R.drawable.ic_round_star_border_24px: case R.drawable.ic_round_star_border_24px:
bookmarkLocationDao.updateBookmarkLocation(selectedPlace); presenter.toggleBookmarkedStatus(selectedPlace);
updateBookmarkButtonImage(selectedPlace); updateBookmarkButtonImage(selectedPlace);
isBookmarked = bookmarkLocationDao.findBookmarkLocation(selectedPlace);
updateMarker(isBookmarked, selectedPlace, locationManager.getLastLocation());
binding.map.invalidate();
break; break;
case R.drawable.ic_round_star_filled_24px: case R.drawable.ic_round_star_filled_24px:
bookmarkLocationDao.updateBookmarkLocation(selectedPlace); presenter.toggleBookmarkedStatus(selectedPlace);
updateBookmarkButtonImage(selectedPlace); updateBookmarkButtonImage(selectedPlace);
isBookmarked = bookmarkLocationDao.findBookmarkLocation(selectedPlace);
updateMarker(isBookmarked, selectedPlace, locationManager.getLastLocation());
binding.map.invalidate();
break; break;
case R.drawable.ic_directions_black_24dp: case R.drawable.ic_directions_black_24dp:
Utils.handleGeoCoordinates(this.getContext(), selectedPlace.getLocation()); Utils.handleGeoCoordinates(this.getContext(), selectedPlace.getLocation());

View file

@ -0,0 +1,10 @@
package fr.free.nrw.commons.nearby.model
import fr.free.nrw.commons.location.LatLng
sealed class NearbyQueryParams {
class Rectangular(val screenTopRight: LatLng, val screenBottomLeft: LatLng) :
NearbyQueryParams()
class Radial(val center: LatLng, val radiusInKm: Float) : NearbyQueryParams()
}

View file

@ -1,368 +0,0 @@
package fr.free.nrw.commons.nearby.presenter;
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.CUSTOM_QUERY;
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED;
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.LOCATION_SLIGHTLY_CHANGED;
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.MAP_UPDATED;
import static fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType.SEARCH_CUSTOM_AREA;
import static fr.free.nrw.commons.nearby.CheckBoxTriStates.CHECKED;
import static fr.free.nrw.commons.nearby.CheckBoxTriStates.UNCHECKED;
import static fr.free.nrw.commons.nearby.CheckBoxTriStates.UNKNOWN;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import android.location.Location;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType;
import fr.free.nrw.commons.location.LocationUpdateListener;
import fr.free.nrw.commons.nearby.CheckBoxTriStates;
import fr.free.nrw.commons.nearby.Label;
import fr.free.nrw.commons.nearby.MarkerPlaceGroup;
import fr.free.nrw.commons.nearby.NearbyController;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract;
import fr.free.nrw.commons.utils.LocationUtils;
import fr.free.nrw.commons.wikidata.WikidataEditListener;
import java.lang.reflect.Proxy;
import java.util.List;
import timber.log.Timber;
public class NearbyParentFragmentPresenter
implements NearbyParentFragmentContract.UserActions,
WikidataEditListener.WikidataP18EditListener,
LocationUpdateListener {
private boolean isNearbyLocked;
private LatLng currentLatLng;
private boolean placesLoadedOnce;
BookmarkLocationsDao bookmarkLocationDao;
private @Nullable String customQuery;
private static final NearbyParentFragmentContract.View DUMMY = (NearbyParentFragmentContract.View) Proxy.newProxyInstance(
NearbyParentFragmentContract.View.class.getClassLoader(),
new Class[]{NearbyParentFragmentContract.View.class}, (proxy, method, args) -> {
if (method.getName().equals("onMyEvent")) {
return null;
} else if (String.class == method.getReturnType()) {
return "";
} else if (Integer.class == method.getReturnType()) {
return Integer.valueOf(0);
} else if (int.class == method.getReturnType()) {
return 0;
} else if (Boolean.class == method.getReturnType()) {
return Boolean.FALSE;
} else if (boolean.class == method.getReturnType()) {
return false;
} else {
return null;
}
}
);
private NearbyParentFragmentContract.View nearbyParentFragmentView = DUMMY;
public NearbyParentFragmentPresenter(BookmarkLocationsDao bookmarkLocationDao) {
this.bookmarkLocationDao = bookmarkLocationDao;
}
@Override
public void attachView(NearbyParentFragmentContract.View view) {
this.nearbyParentFragmentView = view;
}
@Override
public void detachView() {
this.nearbyParentFragmentView = DUMMY;
}
@Override
public void removeNearbyPreferences(JsonKvStore applicationKvStore) {
Timber.d("Remove place objects");
applicationKvStore.remove(PLACE_OBJECT);
}
public void initializeMapOperations() {
lockUnlockNearby(false);
updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED);
nearbyParentFragmentView.setCheckBoxAction();
}
/**
* Sets click listeners of FABs, and 2 bottom sheets
*/
@Override
public void setActionListeners(JsonKvStore applicationKvStore) {
nearbyParentFragmentView.setFABPlusAction(v -> {
if (applicationKvStore.getBoolean("login_skipped", false)) {
// prompt the user to login
nearbyParentFragmentView.displayLoginSkippedWarning();
} else {
nearbyParentFragmentView.animateFABs();
}
});
nearbyParentFragmentView.setFABRecenterAction(v -> {
nearbyParentFragmentView.recenterMap(currentLatLng);
});
}
@Override
public boolean backButtonClicked() {
if (nearbyParentFragmentView.isAdvancedQueryFragmentVisible()) {
nearbyParentFragmentView.showHideAdvancedQueryFragment(false);
return true;
} else if (nearbyParentFragmentView.isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
nearbyParentFragmentView.listOptionMenuItemClicked();
return true;
} else if (nearbyParentFragmentView.isDetailsBottomSheetVisible()) {
nearbyParentFragmentView.setBottomSheetDetailsSmaller();
return true;
}
return false;
}
public void markerUnselected() {
nearbyParentFragmentView.hideBottomSheet();
}
/**
* Nearby updates takes time, since they are network operations. During update time, we don't
* want to get any other calls from user. So locking nearby.
*
* @param isNearbyLocked true means lock, false means unlock
*/
@Override
public void lockUnlockNearby(boolean isNearbyLocked) {
this.isNearbyLocked = isNearbyLocked;
if (isNearbyLocked) {
nearbyParentFragmentView.disableFABRecenter();
} else {
nearbyParentFragmentView.enableFABRecenter();
}
}
/**
* This method should be the single point to update Map and List. Triggered by location changes
*
* @param locationChangeType defines if location changed significantly or slightly
*/
@Override
public void updateMapAndList(LocationChangeType locationChangeType) {
Timber.d("Presenter updates map and list");
if (isNearbyLocked) {
Timber.d("Nearby is locked, so updateMapAndList returns");
return;
}
if (!nearbyParentFragmentView.isNetworkConnectionEstablished()) {
Timber.d("Network connection is not established");
return;
}
LatLng lastLocation = nearbyParentFragmentView.getLastMapFocus();
if (nearbyParentFragmentView.getMapCenter() != null) {
currentLatLng = nearbyParentFragmentView.getMapCenter();
} else {
currentLatLng = lastLocation;
}
if (currentLatLng == null) {
Timber.d("Skipping update of nearby places as location is unavailable");
return;
}
/**
* Significant changed - Markers and current location will be updated together
* Slightly changed - Only current position marker will be updated
*/
if (locationChangeType.equals(CUSTOM_QUERY)) {
Timber.d("ADVANCED_QUERY_SEARCH");
lockUnlockNearby(true);
nearbyParentFragmentView.setProgressBarVisibility(true);
LatLng updatedLocationByUser = LocationUtils.deriveUpdatedLocationFromSearchQuery(
customQuery);
if (updatedLocationByUser == null) {
updatedLocationByUser = lastLocation;
}
nearbyParentFragmentView.populatePlaces(updatedLocationByUser, customQuery);
} else if (locationChangeType.equals(LOCATION_SIGNIFICANTLY_CHANGED)
|| locationChangeType.equals(MAP_UPDATED)) {
lockUnlockNearby(true);
nearbyParentFragmentView.setProgressBarVisibility(true);
nearbyParentFragmentView.populatePlaces(nearbyParentFragmentView.getMapCenter());
} else if (locationChangeType.equals(SEARCH_CUSTOM_AREA)) {
Timber.d("SEARCH_CUSTOM_AREA");
lockUnlockNearby(true);
nearbyParentFragmentView.setProgressBarVisibility(true);
nearbyParentFragmentView.populatePlaces(nearbyParentFragmentView.getMapFocus());
} else { // Means location changed slightly, ie user is walking or driving.
Timber.d("Means location changed slightly");
}
}
/**
* Populates places for custom location, should be used for finding nearby places around a
* location where you are not at.
*
* @param nearbyPlaces This variable has placeToCenter list information and distances.
*/
public void updateMapMarkers(List<Place> nearbyPlaces, LatLng currentLatLng,
boolean shouldTrackPosition) {
if (null != nearbyParentFragmentView) {
nearbyParentFragmentView.clearAllMarkers();
List<BaseMarker> baseMarkers = NearbyController
.loadAttractionsFromLocationToBaseMarkerOptions(currentLatLng,
// Curlatlang will be used to calculate distances
nearbyPlaces);
nearbyParentFragmentView.updateMapMarkers(baseMarkers);
lockUnlockNearby(false); // So that new location updates wont come
nearbyParentFragmentView.setProgressBarVisibility(false);
nearbyParentFragmentView.updateListFragment(nearbyPlaces);
}
}
/**
* Some centering task may need to wait for map to be ready, if they are requested before map is
* ready. So we will remember it when the map is ready
*/
private void handleCenteringTaskIfAny() {
if (!placesLoadedOnce) {
placesLoadedOnce = true;
nearbyParentFragmentView.centerMapToPlace(null);
}
}
@Override
public void onWikidataEditSuccessful() {
updateMapAndList(MAP_UPDATED);
}
@Override
public void onLocationChangedSignificantly(LatLng latLng) {
Timber.d("Location significantly changed");
updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED);
}
@Override
public void onLocationChangedSlightly(LatLng latLng) {
Timber.d("Location significantly changed");
updateMapAndList(LOCATION_SLIGHTLY_CHANGED);
}
@Override
public void onLocationChangedMedium(LatLng latLng) {
Timber.d("Location changed medium");
}
@Override
public void filterByMarkerType(List<Label> selectedLabels, int state,
boolean filterForPlaceState, boolean filterForAllNoneType) {
if (filterForAllNoneType) {// Means we will set labels based on states
switch (state) {
case UNKNOWN:
// Do nothing
break;
case UNCHECKED:
//TODO
nearbyParentFragmentView.filterOutAllMarkers();
nearbyParentFragmentView.setRecyclerViewAdapterItemsGreyedOut();
break;
case CHECKED:
// Despite showing all labels NearbyFilterState still should be applied
nearbyParentFragmentView.filterMarkersByLabels(selectedLabels,
filterForPlaceState, false);
nearbyParentFragmentView.setRecyclerViewAdapterAllSelected();
break;
}
} else {
nearbyParentFragmentView.filterMarkersByLabels(selectedLabels,
filterForPlaceState, false);
}
}
@Override
@MainThread
public void updateMapMarkersToController(List<BaseMarker> baseMarkers) {
NearbyController.markerLabelList.clear();
for (int i = 0; i < baseMarkers.size(); i++) {
BaseMarker nearbyBaseMarker = baseMarkers.get(i);
NearbyController.markerLabelList.add(
new MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(nearbyBaseMarker.getPlace()),
nearbyBaseMarker.getPlace()));
}
}
@Override
public void setCheckboxUnknown() {
nearbyParentFragmentView.setCheckBoxState(CheckBoxTriStates.UNKNOWN);
}
@Override
public void setAdvancedQuery(String query) {
this.customQuery = query;
}
@Override
public void searchViewGainedFocus() {
if (nearbyParentFragmentView.isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
nearbyParentFragmentView.hideBottomSheet();
} else if (nearbyParentFragmentView.isDetailsBottomSheetVisible()) {
nearbyParentFragmentView.hideBottomDetailsSheet();
}
}
/**
* Initiates a search for places within the area. Depending on whether the search
* is close to the current location, the map and list are updated
* accordingly.
*/
public void searchInTheArea(){
if (searchCloseToCurrentLocation()) {
updateMapAndList(LOCATION_SIGNIFICANTLY_CHANGED);
} else {
updateMapAndList(SEARCH_CUSTOM_AREA);
}
}
/**
* Returns true if search this area button is used around our current location, so that we can
* continue following our current location again
*
* @return Returns true if search this area button is used around our current location
*/
public boolean searchCloseToCurrentLocation() {
if (null == nearbyParentFragmentView.getLastMapFocus()) {
return true;
}
//TODO
Location mylocation = new Location("");
Location dest_location = new Location("");
dest_location.setLatitude(nearbyParentFragmentView.getMapFocus().getLatitude());
dest_location.setLongitude(nearbyParentFragmentView.getMapFocus().getLongitude());
mylocation.setLatitude(nearbyParentFragmentView.getLastMapFocus().getLatitude());
mylocation.setLongitude(nearbyParentFragmentView.getLastMapFocus().getLongitude());
Float distance = mylocation.distanceTo(dest_location);
if (distance > 2000.0 * 3 / 4) {
return false;
} else {
return true;
}
}
public void onMapReady() {
if (null != nearbyParentFragmentView) {
initializeMapOperations();
}
}
}

View file

@ -0,0 +1,657 @@
package fr.free.nrw.commons.nearby.presenter
import android.location.Location
import android.view.View
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleCoroutineScope
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.location.LocationServiceManager.LocationChangeType
import fr.free.nrw.commons.location.LocationUpdateListener
import fr.free.nrw.commons.nearby.CheckBoxTriStates
import fr.free.nrw.commons.nearby.Label
import fr.free.nrw.commons.nearby.MarkerPlaceGroup
import fr.free.nrw.commons.nearby.NearbyController
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.PlacesRepository
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract
import fr.free.nrw.commons.utils.LocationUtils
import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
import fr.free.nrw.commons.wikidata.WikidataEditListener.WikidataP18EditListener
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import java.util.concurrent.CopyOnWriteArrayList
class NearbyParentFragmentPresenter
(
val bookmarkLocationDao: BookmarkLocationsDao,
val placesRepository: PlacesRepository,
val nearbyController: NearbyController
) :
NearbyParentFragmentContract.UserActions,
WikidataP18EditListener, LocationUpdateListener {
private var isNearbyLocked = false
private var currentLatLng: LatLng? = null
private var placesLoadedOnce = false
private var customQuery: String? = null
private var nearbyParentFragmentView: NearbyParentFragmentContract.View = DUMMY
private var placeSearchJob: Job? = null
private var isSearchInProgress = false
private var localPlaceSearchJob: Job? = null
private val clickedPlaces = CopyOnWriteArrayList<Place>()
/**
* used to tell the asynchronous place detail loading job that a pin was clicked
* so as to prevent it from turning grey on the next pin detail update
*
* @param place the place whose details have already been loaded because clicked pin
*/
fun handlePinClicked(place: Place) {
clickedPlaces.add(place)
}
// the currently running job for async loading of pin details, cancelled when new pins are come
private var loadPlacesDataAyncJob: Job? = null
/**
* - **batchSize**: number of places to fetch details of in a single request
* - **connnectionCount**: number of parallel requests
*/
private object LoadPlacesAsyncOptions {
const val BATCH_SIZE = 3
const val CONNECTION_COUNT = 3
}
private var schedulePlacesUpdateJob: Job? = null
/**
* - **skippedCount**: stores the number of updates skipped
* - **skipLimit**: maximum number of consecutive updates that can be skipped
* - **skipDelayMs**: The delay (in milliseconds) to wait for a new update.
*
* @see schedulePlacesUpdate
*/
private object SchedulePlacesUpdateOptions {
var skippedCount = 0
const val SKIP_LIMIT = 3
const val SKIP_DELAY_MS = 500L
}
// used to tell the asynchronous place detail loading job that the places' bookmarked status
// changed so as to prevent inconsistencies
private var bookmarkChangedPlaces = CopyOnWriteArrayList<Place>()
/**
* Schedules a UI update for the provided list of `MarkerPlaceGroup` objects. Since, the update
* is performed on the main thread, it waits for a `SchedulePlacesUpdateOptions.skipDelayMs`
* to see if a new update comes, and if one does, it discards the scheduled UI update.
*
* @param markerPlaceGroups The new list of `MarkerPlaceGroup` objects. If the list is empty, no
* update will be performed.
*
* @see SchedulePlacesUpdateOptions
*/
private suspend fun schedulePlacesUpdate(
markerPlaceGroups: List<MarkerPlaceGroup>,
force: Boolean = false
) =
withContext(Dispatchers.Main) {
if (markerPlaceGroups.isEmpty()) return@withContext
schedulePlacesUpdateJob?.cancel()
schedulePlacesUpdateJob = launch {
if (!force && SchedulePlacesUpdateOptions.skippedCount++
< SchedulePlacesUpdateOptions.SKIP_LIMIT
) {
delay(SchedulePlacesUpdateOptions.SKIP_DELAY_MS)
}
SchedulePlacesUpdateOptions.skippedCount = 0
updatePlaceGroupsToControllerAndRender(markerPlaceGroups)
}
}
/**
* Handles the user action of toggling the bookmarked status of a given place. Updates the
* bookmark status in the database, updates the UI to reflect the new state.
*
* @param place The place whose bookmarked status is to be toggled. If the place is `null`,
* the operation is skipped.
*/
override fun toggleBookmarkedStatus(place: Place?) {
if (place == null) return
val nowBookmarked = bookmarkLocationDao.updateBookmarkLocation(place)
bookmarkChangedPlaces.add(place)
val placeIndex =
NearbyController.markerLabelList.indexOfFirst { it.place.location == place.location }
NearbyController.markerLabelList[placeIndex] = MarkerPlaceGroup(
nowBookmarked,
NearbyController.markerLabelList[placeIndex].place
)
nearbyParentFragmentView.setFilterState()
}
override fun attachView(view: NearbyParentFragmentContract.View) {
this.nearbyParentFragmentView = view
}
override fun detachView() {
this.nearbyParentFragmentView = DUMMY
}
override fun removeNearbyPreferences(applicationKvStore: JsonKvStore) {
Timber.d("Remove place objects")
applicationKvStore.remove(PLACE_OBJECT)
}
fun initializeMapOperations() {
lockUnlockNearby(false)
updateMapAndList(LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED)
nearbyParentFragmentView.setCheckBoxAction()
}
/**
* Sets click listeners of FABs, and 2 bottom sheets
*/
override fun setActionListeners(applicationKvStore: JsonKvStore?) {
nearbyParentFragmentView.setFABPlusAction(View.OnClickListener { v: View? ->
if (applicationKvStore != null && applicationKvStore.getBoolean(
"login_skipped", false
)
) {
// prompt the user to login
nearbyParentFragmentView.displayLoginSkippedWarning()
} else {
nearbyParentFragmentView.animateFABs()
}
})
nearbyParentFragmentView.setFABRecenterAction(View.OnClickListener { v: View? ->
nearbyParentFragmentView.recenterMap(currentLatLng)
})
}
override fun backButtonClicked(): Boolean {
if (nearbyParentFragmentView.isAdvancedQueryFragmentVisible()) {
nearbyParentFragmentView.showHideAdvancedQueryFragment(false)
return true
} else if (nearbyParentFragmentView.isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
nearbyParentFragmentView.listOptionMenuItemClicked()
return true
} else if (nearbyParentFragmentView.isDetailsBottomSheetVisible()) {
nearbyParentFragmentView.setBottomSheetDetailsSmaller()
return true
}
return false
}
fun markerUnselected() {
nearbyParentFragmentView.hideBottomSheet()
}
/**
* Nearby updates takes time, since they are network operations. During update time, we don't
* want to get any other calls from user. So locking nearby.
*
* @param isNearbyLocked true means lock, false means unlock
*/
override fun lockUnlockNearby(isNearbyLocked: Boolean) {
this.isNearbyLocked = isNearbyLocked
if (isNearbyLocked) {
nearbyParentFragmentView.disableFABRecenter()
} else {
nearbyParentFragmentView.enableFABRecenter()
}
}
/**
* This method should be the single point to update Map and List. Triggered by location changes
*
* @param locationChangeType defines if location changed significantly or slightly
*/
override fun updateMapAndList(locationChangeType: LocationChangeType?) {
Timber.d("Presenter updates map and list")
if (isNearbyLocked) {
Timber.d("Nearby is locked, so updateMapAndList returns")
return
}
if (!nearbyParentFragmentView.isNetworkConnectionEstablished()) {
Timber.d("Network connection is not established")
return
}
val lastLocation = nearbyParentFragmentView.getLastMapFocus()
currentLatLng = if (nearbyParentFragmentView.getMapCenter() != null) {
nearbyParentFragmentView.getMapCenter()
} else {
lastLocation
}
if (currentLatLng == null) {
Timber.d("Skipping update of nearby places as location is unavailable")
return
}
/**
* Significant changed - Markers and current location will be updated together
* Slightly changed - Only current position marker will be updated
*/
if (locationChangeType == LocationChangeType.CUSTOM_QUERY) {
Timber.d("ADVANCED_QUERY_SEARCH")
lockUnlockNearby(true)
nearbyParentFragmentView.setProgressBarVisibility(true)
var updatedLocationByUser = LocationUtils.deriveUpdatedLocationFromSearchQuery(
customQuery!!
)
if (updatedLocationByUser == null) {
updatedLocationByUser = lastLocation
}
nearbyParentFragmentView.populatePlaces(updatedLocationByUser, customQuery)
} else if (locationChangeType == LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED
|| locationChangeType == LocationChangeType.MAP_UPDATED
) {
lockUnlockNearby(true)
nearbyParentFragmentView.setProgressBarVisibility(true)
nearbyParentFragmentView.populatePlaces(nearbyParentFragmentView.getMapCenter())
} else if (locationChangeType == LocationChangeType.SEARCH_CUSTOM_AREA) {
Timber.d("SEARCH_CUSTOM_AREA")
lockUnlockNearby(true)
nearbyParentFragmentView.setProgressBarVisibility(true)
nearbyParentFragmentView.populatePlaces(nearbyParentFragmentView.getMapFocus())
} else { // Means location changed slightly, ie user is walking or driving.
Timber.d("Means location changed slightly")
}
}
/**
* Update places on the map, and asynchronously load their details from cache and Wikidata query
*
* @param nearbyPlaces This variable has the list of placecs
* @param scope the lifecycle scope of `nearbyParentFragment`'s `viewLifecycleOwner`
*/
fun updateMapMarkers(
nearbyPlaces: List<Place>?, currentLatLng: LatLng,
scope: LifecycleCoroutineScope?
) {
val nearbyPlaceGroups = nearbyPlaces?.sortedBy { it.getDistanceInDouble(currentLatLng) }
?.take(NearbyController.MAX_RESULTS)
?.map {
// currently only the place's location is known but bookmarks are stored by name
MarkerPlaceGroup(
false,
it
)
}
?: return
lockUnlockNearby(false) // So that new location updates wont come
nearbyParentFragmentView.setProgressBarVisibility(false)
loadPlacesDataAsync(nearbyPlaceGroups, scope)
}
/**
* Load the places' details from cache and Wikidata query, and update these details on the map
* as and when they arrive.
*
* @param nearbyPlaceGroups The list of `MarkerPlaceGroup` objects to be rendered on the map.
* Note that the supplied objects' `isBookmarked` property can be set false as the actual
* value is retrieved from the bookmarks db eventually.
* @param scope the lifecycle scope of `nearbyParentFragment`'s `viewLifecycleOwner`
*
* @see LoadPlacesAsyncOptions
*/
fun loadPlacesDataAsync(
nearbyPlaceGroups: List<MarkerPlaceGroup>,
scope: LifecycleCoroutineScope?
) {
loadPlacesDataAyncJob?.cancel()
loadPlacesDataAyncJob = scope?.launch(Dispatchers.IO) {
// clear past clicks and bookmarkChanged queues
clickedPlaces.clear()
bookmarkChangedPlaces.clear()
var clickedPlacesIndex = 0
var bookmarkChangedPlacesIndex = 0
val updatedGroups = nearbyPlaceGroups.toMutableList()
// first load cached places:
val indicesToUpdate = mutableListOf<Int>()
for (i in 0..updatedGroups.lastIndex) {
val repoPlace = placesRepository.fetchPlace(updatedGroups[i].place.entityID)
if (repoPlace != null && repoPlace.name != null && repoPlace.name != ""){
updatedGroups[i].isBookmarked = bookmarkLocationDao.findBookmarkLocation(repoPlace)
updatedGroups[i].place.apply {
name = repoPlace.name
isMonument = repoPlace.isMonument
pic = repoPlace.pic ?: ""
exists = repoPlace.exists ?: true
longDescription = repoPlace.longDescription ?: ""
}
} else {
indicesToUpdate.add(i)
}
}
schedulePlacesUpdate(updatedGroups, force = true)
// channel for lists of indices of places, each list to be fetched in a single request
val fetchPlacesChannel = Channel<List<Int>>(Channel.UNLIMITED)
var totalBatches = 0
for (i in indicesToUpdate.indices step LoadPlacesAsyncOptions.BATCH_SIZE) {
++totalBatches
fetchPlacesChannel.send(
indicesToUpdate.slice(
i until (i + LoadPlacesAsyncOptions.BATCH_SIZE).coerceAtMost(
indicesToUpdate.size
)
)
)
}
fetchPlacesChannel.close()
val collectResults = Channel<List<Pair<Int, MarkerPlaceGroup>>>(totalBatches)
repeat(LoadPlacesAsyncOptions.CONNECTION_COUNT) {
launch(Dispatchers.IO) {
for (indices in fetchPlacesChannel) {
ensureActive()
try {
val fetchedPlaces =
nearbyController.getPlaces(indices.map { updatedGroups[it].place })
collectResults.send(
fetchedPlaces.mapIndexed { index, place ->
Pair(indices[index], MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(place),
place
))
}
)
} catch (e: Exception) {
Timber.tag("NearbyPinDetails").e(e)
collectResults.send(indices.map { Pair(it, updatedGroups[it]) })
}
}
}
}
var collectCount = 0
for (resultList in collectResults) {
for ((index, fetchedPlaceGroup) in resultList) {
val existingPlace = updatedGroups[index].place
val finalPlaceGroup = MarkerPlaceGroup(
fetchedPlaceGroup.isBookmarked,
fetchedPlaceGroup.place.apply {
location = existingPlace.location
distance = existingPlace.distance
isMonument = existingPlace.isMonument
}
)
updatedGroups[index] = finalPlaceGroup
placesRepository
.save(finalPlaceGroup.place)
.subscribeOn(Schedulers.io())
.subscribe()
}
// handle any places clicked
if (clickedPlacesIndex < clickedPlaces.size) {
val clickedPlacesBacklog = hashMapOf<LatLng, Place>()
while (clickedPlacesIndex < clickedPlaces.size) {
clickedPlacesBacklog.put(
clickedPlaces[clickedPlacesIndex].location,
clickedPlaces[clickedPlacesIndex]
)
++clickedPlacesIndex
}
for ((index, group) in updatedGroups.withIndex()) {
if (clickedPlacesBacklog.containsKey(group.place.location)) {
updatedGroups[index] = MarkerPlaceGroup(
updatedGroups[index].isBookmarked,
clickedPlacesBacklog[group.place.location]
)
}
}
}
// handle any bookmarks toggled
if (bookmarkChangedPlacesIndex < bookmarkChangedPlaces.size) {
val bookmarkChangedPlacesBacklog = hashMapOf<LatLng, Place>()
while (bookmarkChangedPlacesIndex < bookmarkChangedPlaces.size) {
bookmarkChangedPlacesBacklog.put(
bookmarkChangedPlaces[bookmarkChangedPlacesIndex].location,
bookmarkChangedPlaces[bookmarkChangedPlacesIndex]
)
++bookmarkChangedPlacesIndex
}
for ((index, group) in updatedGroups.withIndex()) {
if (bookmarkChangedPlacesBacklog.containsKey(group.place.location)) {
updatedGroups[index] = MarkerPlaceGroup(
bookmarkLocationDao
.findBookmarkLocation(updatedGroups[index].place),
updatedGroups[index].place
)
}
}
}
schedulePlacesUpdate(updatedGroups)
if (++collectCount == totalBatches) {
break
}
}
collectResults.close()
}
}
/**
* Some centering task may need to wait for map to be ready, if they are requested before map is
* ready. So we will remember it when the map is ready
*/
private fun handleCenteringTaskIfAny() {
if (!placesLoadedOnce) {
placesLoadedOnce = true
nearbyParentFragmentView.centerMapToPlace(null)
}
}
override fun onWikidataEditSuccessful() {
updateMapAndList(LocationChangeType.MAP_UPDATED)
}
override fun onLocationChangedSignificantly(latLng: LatLng) {
Timber.d("Location significantly changed")
updateMapAndList(LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED)
}
override fun onLocationChangedSlightly(latLng: LatLng) {
Timber.d("Location significantly changed")
updateMapAndList(LocationChangeType.LOCATION_SLIGHTLY_CHANGED)
}
override fun onLocationChangedMedium(latLng: LatLng) {
Timber.d("Location changed medium")
}
override fun filterByMarkerType(
selectedLabels: List<Label?>?, state: Int,
filterForPlaceState: Boolean, filterForAllNoneType: Boolean
) {
if (filterForAllNoneType) { // Means we will set labels based on states
when (state) {
CheckBoxTriStates.UNKNOWN -> {}
CheckBoxTriStates.UNCHECKED -> {
//TODO
nearbyParentFragmentView.filterOutAllMarkers()
nearbyParentFragmentView.setRecyclerViewAdapterItemsGreyedOut()
}
CheckBoxTriStates.CHECKED -> {
// Despite showing all labels NearbyFilterState still should be applied
nearbyParentFragmentView.filterMarkersByLabels(
selectedLabels,
filterForPlaceState, false
)
nearbyParentFragmentView.setRecyclerViewAdapterAllSelected()
}
}
} else {
nearbyParentFragmentView.filterMarkersByLabels(
selectedLabels,
filterForPlaceState, false
)
}
}
/**
* Handles the map scroll user action for `NearbyParentFragment`
*
* @param scope The lifecycle scope of `nearbyParentFragment`'s `viewLifecycleOwner`
* @param isNetworkAvailable Whether to load pins from the internet or from the cache.
*/
@Override
override fun handleMapScrolled(scope: LifecycleCoroutineScope?, isNetworkAvailable: Boolean) {
scope ?: return
placeSearchJob?.cancel()
localPlaceSearchJob?.cancel()
if (isNetworkAvailable) {
placeSearchJob = scope.launch(Dispatchers.Main) {
delay(SCROLL_DELAY)
if (!isSearchInProgress) {
isSearchInProgress = true; // search executing flag
// Start Search
try {
searchInTheArea();
} finally {
isSearchInProgress = false;
}
}
}
} else {
loadPlacesDataAyncJob?.cancel()
localPlaceSearchJob = scope.launch(Dispatchers.IO) {
delay(LOCAL_SCROLL_DELAY)
val mapFocus = nearbyParentFragmentView.mapFocus
val markerPlaceGroups = placesRepository.fetchPlaces(
nearbyParentFragmentView.screenBottomLeft,
nearbyParentFragmentView.screenTopRight
).sortedBy { it.getDistanceInDouble(mapFocus) }.take(NearbyController.MAX_RESULTS)
.map {
MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(it), it
)
}
ensureActive()
NearbyController.currentLocation = mapFocus
schedulePlacesUpdate(markerPlaceGroups, force = true)
withContext(Dispatchers.Main) {
nearbyParentFragmentView.updateSnackbar(!markerPlaceGroups.isEmpty())
}
}
}
}
/**
* Sends the supplied markerPlaceGroups to `NearbyController` and nearby list fragment,
* and tells nearby parent fragment to filter the updated values to be rendered as overlays
* on the map
*
* @param markerPlaceGroups the new/updated list of places along with their bookmarked status
*/
@MainThread
private fun updatePlaceGroupsToControllerAndRender(markerPlaceGroups: List<MarkerPlaceGroup>) {
NearbyController.markerLabelList.clear()
NearbyController.markerLabelList.addAll(markerPlaceGroups)
nearbyParentFragmentView.setFilterState()
nearbyParentFragmentView.updateListFragment(markerPlaceGroups.map { it.place })
}
override fun setCheckboxUnknown() {
nearbyParentFragmentView.setCheckBoxState(CheckBoxTriStates.UNKNOWN)
}
override fun setAdvancedQuery(query: String) {
this.customQuery = query
}
override fun searchViewGainedFocus() {
if (nearbyParentFragmentView.isListBottomSheetExpanded()) {
// Back should first hide the bottom sheet if it is expanded
nearbyParentFragmentView.hideBottomSheet()
} else if (nearbyParentFragmentView.isDetailsBottomSheetVisible()) {
nearbyParentFragmentView.hideBottomDetailsSheet()
}
}
/**
* Initiates a search for places within the area. Depending on whether the search
* is close to the current location, the map and list are updated
* accordingly.
*/
fun searchInTheArea() {
if (searchCloseToCurrentLocation()) {
updateMapAndList(LocationChangeType.LOCATION_SIGNIFICANTLY_CHANGED)
} else {
updateMapAndList(LocationChangeType.SEARCH_CUSTOM_AREA)
}
}
/**
* Returns true if search this area button is used around our current location, so that we can
* continue following our current location again
*
* @return Returns true if search this area button is used around our current location
*/
fun searchCloseToCurrentLocation(): Boolean {
if (null == nearbyParentFragmentView.getLastMapFocus()) {
return true
}
//TODO
val myLocation = Location("")
val destLocation = Location("")
destLocation.latitude = nearbyParentFragmentView.getMapFocus().latitude
destLocation.longitude = nearbyParentFragmentView.getMapFocus().longitude
myLocation.latitude = nearbyParentFragmentView.getLastMapFocus().latitude
myLocation.longitude = nearbyParentFragmentView.getLastMapFocus().longitude
val distance = myLocation.distanceTo(destLocation)
return (distance <= 2000.0 * 3 / 4)
}
fun onMapReady() {
initializeMapOperations()
}
companion object {
private const val SCROLL_DELAY = 800L; // Delay for debounce of onscroll, in milliseconds.
private const val LOCAL_SCROLL_DELAY = 200L; // SCROLL_DELAY but for local db place search
private val DUMMY = Proxy.newProxyInstance(
NearbyParentFragmentContract.View::class.java.getClassLoader(),
arrayOf<Class<*>>(NearbyParentFragmentContract.View::class.java),
InvocationHandler { proxy: Any?, method: Method?, args: Array<Any?>? ->
if (method!!.name == "onMyEvent") {
return@InvocationHandler null
} else if (String::class.java == method.returnType) {
return@InvocationHandler ""
} else if (Int::class.java == method.returnType) {
return@InvocationHandler 0
} else if (Int::class.javaPrimitiveType == method.returnType) {
return@InvocationHandler 0
} else if (Boolean::class.java == method.returnType) {
return@InvocationHandler java.lang.Boolean.FALSE
} else if (Boolean::class.javaPrimitiveType == method.returnType) {
return@InvocationHandler false
} else {
return@InvocationHandler null
}
}
) as NearbyParentFragmentContract.View
}
}

View file

@ -139,6 +139,7 @@ class QuizActivity : AppCompatActivity() {
.setTitle(title) .setTitle(title)
.setMessage(message) .setMessage(message)
.setCancelable(false) .setCancelable(false)
.setNegativeButton(R.string.cancel){_,_ -> }
.setPositiveButton(R.string.continue_message) { dialog, _ -> .setPositiveButton(R.string.continue_message) { dialog, _ ->
questionIndex++ questionIndex++
if (questionIndex == quiz.size) { if (questionIndex == quiz.size) {

View file

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

View file

@ -61,7 +61,12 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
R.string.review_category_explanation, R.string.review_category_explanation,
formattedCatString formattedCatString
) )
return Html.fromHtml(stringToConvertHtml).toString() val formattedString = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
Html.fromHtml(stringToConvertHtml, Html.FROM_HTML_MODE_LEGACY).toString()
} else {
Html.fromHtml(stringToConvertHtml).toString()
}
return formattedString
} }
} }
return getString(R.string.review_no_category) return getString(R.string.review_no_category)

View file

@ -1,11 +1,7 @@
package fr.free.nrw.commons.settings package fr.free.nrw.commons.settings
object Prefs { object Prefs {
const val GLOBAL_PREFS = "fr.free.nrw.commons.preferences"
const val TRACKING_ENABLED = "eventLogging"
const val DEFAULT_LICENSE = "defaultLicense" const val DEFAULT_LICENSE = "defaultLicense"
const val UPLOADS_SHOWING = "uploadsShowing"
const val MANAGED_EXIF_TAGS = "managed_exif_tags" const val MANAGED_EXIF_TAGS = "managed_exif_tags"
const val DESCRIPTION_LANGUAGE = "languageDescription" const val DESCRIPTION_LANGUAGE = "languageDescription"
const val APP_UI_LANGUAGE = "appUiLanguage" const val APP_UI_LANGUAGE = "appUiLanguage"

View file

@ -12,7 +12,6 @@ import fr.free.nrw.commons.theme.BaseActivity
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
private lateinit var binding: ActivitySettingsBinding private lateinit var binding: ActivitySettingsBinding
// private var settingsDelegate: AppCompatDelegate? = null
/** /**
* to be called when the activity starts * to be called when the activity starts

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.settings package fr.free.nrw.commons.settings
import android.Manifest.permission import android.Manifest.permission
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
@ -13,6 +12,7 @@ import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.View import android.view.View
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.ListView import android.widget.ListView
import android.widget.TextView import android.widget.TextView
@ -129,7 +129,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue -> inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue ->
val isInAppCameraLocationTurnedOn = newValue as Boolean val isInAppCameraLocationTurnedOn = newValue as Boolean
if (isInAppCameraLocationTurnedOn) { if (isInAppCameraLocationTurnedOn) {
createDialogsAndHandleLocationPermissions(requireActivity()) createDialogsAndHandleLocationPermissions()
} }
true true
} }
@ -254,7 +254,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
* *
* @param activity * @param activity
*/ */
private fun createDialogsAndHandleLocationPermissions(activity: Activity) { private fun createDialogsAndHandleLocationPermissions() {
inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION)) inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION))
} }
@ -282,7 +282,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
return object : PreferenceGroupAdapter(preferenceScreen) { return object : PreferenceGroupAdapter(preferenceScreen) {
override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) { override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) {
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
val preference = getItem(position)
val iconFrame: View? = holder.itemView.findViewById(R.id.icon_frame) val iconFrame: View? = holder.itemView.findViewById(R.id.icon_frame)
iconFrame?.visibility = View.GONE iconFrame?.visibility = View.GONE
} }
@ -341,6 +340,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
val editText: EditText = dialog.findViewById(R.id.search_language) val editText: EditText = dialog.findViewById(R.id.search_language)
val listView: ListView = dialog.findViewById(R.id.language_list) val listView: ListView = dialog.findViewById(R.id.language_list)
val cancelButton = dialog.findViewById<Button>(R.id.cancel_button)
languageHistoryListView = dialog.findViewById(R.id.language_history_list) languageHistoryListView = dialog.findViewById(R.id.language_history_list)
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches) recentLanguagesTextView = dialog.findViewById(R.id.recent_searches)
separator = dialog.findViewById(R.id.separator) separator = dialog.findViewById(R.id.separator)
@ -349,6 +349,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
listView.adapter = languagesAdapter listView.adapter = languagesAdapter
cancelButton.setOnClickListener { dialog.dismiss() }
editText.addTextChangedListener(object : TextWatcher { editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) { override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) {
hideRecentLanguagesSection() hideRecentLanguagesSection()
@ -378,10 +380,12 @@ class SettingsFragment : PreferenceFragmentCompat() {
if (keyListPreference == "appUiDefaultLanguagePref") { if (keyListPreference == "appUiDefaultLanguagePref") {
appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) appUiLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
setLocale(requireActivity(), lCode) setLocale(requireActivity(), lCode)
requireActivity().recreate()
val intent = Intent(requireActivity(), MainActivity::class.java) val intent = Intent(requireActivity(), MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
requireActivity().finish()
startActivity(intent) startActivity(intent)
} else { }
else {
descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale) descriptionLanguageListPreference?.summary = defLocale.getDisplayLanguage(defLocale)
} }
dialog.dismiss() dialog.dismiss()

View file

@ -1,64 +0,0 @@
package fr.free.nrw.commons.ui.widget
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.Window
import android.view.WindowManager
import androidx.fragment.app.DialogFragment
/**
* A formatted dialog fragment
* This class is used by NearbyInfoDialog
*/
abstract class OverlayDialog : DialogFragment() {
/**
* Creates a DialogFragment with the correct style and theme
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, android.R.style.Theme_Holo_Light)
}
/**
* When the view is created, sets the dialog layout to full screen
*
* @param view the view being used
* @param savedInstanceState bundle re-constructed from a previous saved state
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setDialogLayoutToFullScreen()
super.onViewCreated(view, savedInstanceState)
}
/**
* Sets the dialog layout to fullscreen
*/
private fun setDialogLayoutToFullScreen() {
val window = dialog?.window ?: return
val wlp = window.attributes
window.requestFeature(Window.FEATURE_NO_TITLE)
wlp.gravity = Gravity.BOTTOM
wlp.width = WindowManager.LayoutParams.MATCH_PARENT
wlp.height = WindowManager.LayoutParams.MATCH_PARENT
window.attributes = wlp
}
/**
* Builds custom dialog container
*
* @param savedInstanceState the previously saved state
* @return the dialog
*/
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
return dialog
}
}

View file

@ -1,5 +1,8 @@
package fr.free.nrw.commons.upload package fr.free.nrw.commons.upload
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.net.Uri import android.net.Uri
import android.text.TextUtils import android.text.TextUtils
import android.view.LayoutInflater import android.view.LayoutInflater
@ -13,6 +16,7 @@ import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest import com.facebook.imagepipeline.request.ImageRequest
import com.google.android.material.snackbar.Snackbar
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.Contribution
import java.io.File import java.io.File
@ -51,6 +55,24 @@ class FailedUploadsAdapter(
position: Int, position: Int,
) { ) {
val item: Contribution? = getItem(position) val item: Contribution? = getItem(position)
val itemView = holder.itemView
val clipboardManager =
itemView.context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
itemView.setOnLongClickListener {
val clip = ClipData.newPlainText(
itemView.context.getString(R.string.caption),
item?.media?.displayTitle
)
clipboardManager.setPrimaryClip(clip)
Snackbar.make(
itemView,
itemView.context.getString(R.string.caption_copied_to_clipboard),
Snackbar.LENGTH_SHORT
).show()
true
}
if (item != null) { if (item != null) {
holder.titleTextView.setText(item.media.displayTitle) holder.titleTextView.setText(item.media.displayTitle)
} }

View file

@ -86,7 +86,7 @@ class FileProcessor
*/ */
fun getExifTagsToRedact(): Set<String> { fun getExifTagsToRedact(): Set<String> {
val prefManageEXIFTags = val prefManageEXIFTags =
defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS) ?: emptySet() defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS)
val redactTags: Set<String> = val redactTags: Set<String> =
context.resources.getStringArray(R.array.pref_exifTag_values).toSet() context.resources.getStringArray(R.array.pref_exifTag_values).toSet()
return redactTags - prefManageEXIFTags return redactTags - prefManageEXIFTags

View file

@ -49,9 +49,10 @@ class FileUtilsWrapper @Inject constructor(private val context: Context) {
while ((bis.read(buffer).also { size = it }) > 0) { while ((bis.read(buffer).also { size = it }) > 0) {
buffers.add( buffers.add(
writeToFile( writeToFile(
buffer.copyOf(size), buffer,
file.name ?: "", file.name ?: "",
getFileExt(file.name) getFileExt(file.name),
size
) )
) )
} }
@ -67,7 +68,7 @@ class FileUtilsWrapper @Inject constructor(private val context: Context) {
* Create a temp file containing the passed byte data. * Create a temp file containing the passed byte data.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
private fun writeToFile(data: ByteArray, fileName: String, fileExtension: String): File { private fun writeToFile(data: ByteArray, fileName: String, fileExtension: String, size: Int): File {
val file = File.createTempFile(fileName, fileExtension, context.cacheDir) val file = File.createTempFile(fileName, fileExtension, context.cacheDir)
try { try {
if (!file.exists()) { if (!file.exists()) {
@ -75,7 +76,7 @@ class FileUtilsWrapper @Inject constructor(private val context: Context) {
} }
FileOutputStream(file).use { fos -> FileOutputStream(file).use { fos ->
fos.write(data) fos.write(data, 0, size)
} }
} catch (throwable: Exception) { } catch (throwable: Exception) {
Timber.e(throwable, "Failed to create file") Timber.e(throwable, "Failed to create file")

View file

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

View file

@ -1,5 +1,8 @@
package fr.free.nrw.commons.upload package fr.free.nrw.commons.upload
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.net.Uri import android.net.Uri
import android.text.TextUtils import android.text.TextUtils
import android.view.LayoutInflater import android.view.LayoutInflater
@ -13,6 +16,7 @@ import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest import com.facebook.imagepipeline.request.ImageRequest
import com.google.android.material.snackbar.Snackbar
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.Contribution
import java.io.File import java.io.File
@ -99,6 +103,22 @@ class PendingUploadsAdapter(
fun bind(contribution: Contribution) { fun bind(contribution: Contribution) {
titleTextView.text = contribution.media.displayTitle titleTextView.text = contribution.media.displayTitle
val clipboardManager =
itemView.context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
itemView.setOnLongClickListener {
val clip = ClipData.newPlainText(
itemView.context.getString(R.string.caption),
titleTextView.text
)
clipboardManager.setPrimaryClip(clip)
Snackbar.make(
itemView,
itemView.context.getString(R.string.caption_copied_to_clipboard),
Snackbar.LENGTH_SHORT
).show()
true
}
val imageSource: String = contribution.localUri.toString() val imageSource: String = contribution.localUri.toString()
var imageRequest: ImageRequest? = null var imageRequest: ImageRequest? = null

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() { private void receiveSharedItems() {
ThumbnailsAdapter.context=this;
final Intent intent = getIntent(); final Intent intent = getIntent();
final String action = intent.getAction(); final String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
@ -899,6 +898,7 @@ public class UploadActivity extends BaseActivity implements
.setView(view) .setView(view)
.setTitle(getString(R.string.multiple_files_depiction_header)) .setTitle(getString(R.string.multiple_files_depiction_header))
.setMessage(getString(R.string.multiple_files_depiction)) .setMessage(getString(R.string.multiple_files_depiction))
.setCancelable(false)
.setPositiveButton("OK", (dialog, which) -> { .setPositiveButton("OK", (dialog, which) -> {
if (checkBox.isChecked()) { if (checkBox.isChecked()) {
// Save the user's choice to not show the dialog again // Save the user's choice to not show the dialog again

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() {
var callback: Callback? = null
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

@ -14,6 +14,7 @@ import android.view.View.OnClickListener;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AdapterView; import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemClickListener;
import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageView; import android.widget.ImageView;
@ -302,8 +303,9 @@ public class UploadMediaDetailAdapter extends
removeButton.setOnClickListener(v -> removeDescription(uploadMediaDetail, position)); removeButton.setOnClickListener(v -> removeDescription(uploadMediaDetail, position));
captionListener = new AbstractTextWatcher( captionListener = new AbstractTextWatcher(
captionText -> uploadMediaDetail.setCaptionText(convertIdeographicSpaceToLatinSpace( captionText -> uploadMediaDetail.setCaptionText(
removeLeadingAndTrailingWhitespace(captionText)))); convertIdeographicSpaceToLatinSpace(captionText.strip()))
);
descriptionListener = new AbstractTextWatcher( descriptionListener = new AbstractTextWatcher(
descriptionText -> uploadMediaDetail.setDescriptionText(descriptionText)); descriptionText -> uploadMediaDetail.setDescriptionText(descriptionText));
captionItemEditText.addTextChangedListener(captionListener); captionItemEditText.addTextChangedListener(captionListener);
@ -356,6 +358,7 @@ public class UploadMediaDetailAdapter extends
EditText editText = dialog.findViewById(R.id.search_language); EditText editText = dialog.findViewById(R.id.search_language);
ListView listView = dialog.findViewById(R.id.language_list); ListView listView = dialog.findViewById(R.id.language_list);
final Button cancelButton = dialog.findViewById(R.id.cancel_button);
languageHistoryListView = dialog.findViewById(R.id.language_history_list); languageHistoryListView = dialog.findViewById(R.id.language_history_list);
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches); recentLanguagesTextView = dialog.findViewById(R.id.recent_searches);
separator = dialog.findViewById(R.id.separator); separator = dialog.findViewById(R.id.separator);
@ -363,6 +366,8 @@ public class UploadMediaDetailAdapter extends
listView.setAdapter(languagesAdapter); listView.setAdapter(languagesAdapter);
cancelButton.setOnClickListener(v -> dialog.dismiss());
editText.addTextChangedListener(new TextWatcher() { editText.addTextChangedListener(new TextWatcher() {
@Override @Override
public void beforeTextChanged(CharSequence charSequence, int i, int i1, public void beforeTextChanged(CharSequence charSequence, int i, int i1,
@ -547,39 +552,6 @@ public class UploadMediaDetailAdapter extends
} }
} }
/**
* Removes any leading and trailing whitespace from the source text.
*
* @param source input string
* @return a string without leading and trailing whitespace
*/
public String removeLeadingAndTrailingWhitespace(String source) {
// This method can be replaced with the inbuilt String::strip when updated to JDK 11.
// Note that String::trim does not adequately remove all whitespace chars.
int firstNonWhitespaceIndex = 0;
while (firstNonWhitespaceIndex < source.length()) {
if (Character.isWhitespace(source.charAt(firstNonWhitespaceIndex))) {
firstNonWhitespaceIndex++;
} else {
break;
}
}
if (firstNonWhitespaceIndex == source.length()) {
return "";
}
int lastNonWhitespaceIndex = source.length() - 1;
while (lastNonWhitespaceIndex > firstNonWhitespaceIndex) {
if (Character.isWhitespace(source.charAt(lastNonWhitespaceIndex))) {
lastNonWhitespaceIndex--;
} else {
break;
}
}
return source.substring(firstNonWhitespaceIndex, lastNonWhitespaceIndex + 1);
}
/** /**
* Convert Ideographic space to Latin space * Convert Ideographic space to Latin space
* *

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) {
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

@ -14,7 +14,7 @@ data class UploadResult(
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readString() ?: "", parcel.readString() ?: "",
parcel.readString() ?: "", parcel.readString() ?: "",
parcel.readInt() ?: 0, parcel.readInt(),
parcel.readString() ?: "", parcel.readString() ?: "",
) { ) {
} }

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,425 @@
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!!.setCancelable(false)
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 setSelectedLicense(license: String?)
fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int?) fun updateLicenseSummary(selectedLicense: String?, numberOfItems: Int)
} }
interface UserActionListener : BasePresenter<View> { interface UserActionListener : BasePresenter<View> {
fun getLicenses() fun getLicenses()
fun selectLicense(licenseName: String) fun selectLicense(licenseName: String?)
fun isWLMSupportedForThisPlace(): Boolean 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,206 @@
package fr.free.nrw.commons.upload.license
import android.app.Activity
import android.net.Uri
import android.os.Build
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 = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)
} else {
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

@ -398,7 +398,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) { if (response) {
if (callback != null) { if (callback != null) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace); presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
} }
} }
} else { } else {
@ -445,7 +445,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
() -> { () -> {
// Execute when user confirms the upload is of the specified place // Execute when user confirms the upload is of the specified place
UploadActivity.nearbyPopupAnswers.put(place, true); UploadActivity.nearbyPopupAnswers.put(place, true);
presenter.onUserConfirmedUploadIsOfPlace(place); presenter.onUserConfirmedUploadIsOfPlace(place, indexOfFragment);
}, },
() -> { () -> {
// Execute when user cancels the upload of the specified place // Execute when user cancels the upload of the specified place
@ -486,7 +486,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) { if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace); final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) { if (response) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace); presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
} }
} else { } else {
showNearbyPlaceFound(nearbyPlace); showNearbyPlaceFound(nearbyPlace);
@ -521,7 +521,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
getString(R.string.duplicate_file_name), getString(R.string.duplicate_file_name),
String.format(Locale.getDefault(), String.format(Locale.getDefault(),
uploadTitleFormat, uploadTitleFormat,
uploadItem.getFileName()), uploadItem.getFilename()),
getString(R.string.upload), getString(R.string.upload),
getString(R.string.cancel), getString(R.string.cancel),
() -> { () -> {
@ -714,7 +714,7 @@ public class UploadMediaDetailFragment extends UploadBaseFragment implements
if (binding != null){ if (binding != null){
binding.backgroundImage.setImageURI(Uri.fromFile(new File(path))); 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, callback.changeThumbnail(indexOfFragment,
path); path);
} catch (Exception e) { } catch (Exception e) {

View file

@ -117,6 +117,6 @@ interface UploadMediaDetailsContract {
fun onEditButtonClicked(indexInViewFlipper: Int) fun onEditButtonClicked(indexInViewFlipper: Int)
fun onUserConfirmedUploadIsOfPlace(place: Place?) fun onUserConfirmedUploadIsOfPlace(place: Place?, uploadItemIndex: Int)
} }
} }

View file

@ -106,7 +106,7 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt
*/ */
@Override @Override
public void setUploadMediaDetails(final List<UploadMediaDetail> uploadMediaDetails, final int uploadItemIndex) { 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) { public void copyTitleAndDescriptionToSubsequentMedia(final int indexInViewFlipper) {
for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){ for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){
final UploadItem subsequentUploadItem = repository.getUploads().get(i); final UploadItem subsequentUploadItem = repository.getUploads().get(i);
subsequentUploadItem.setMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails())); subsequentUploadItem.setUploadMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails()));
} }
} }
@ -322,23 +322,24 @@ public class UploadMediaPresenter implements UserActionListener, SimilarImageInt
} }
/** /**
* Updates the information regarding the specified place for uploads * Updates the information regarding the specified place for the specified upload item
* when the user confirms the suggested nearby place. * when the user confirms the suggested nearby place.
* *
* @param place The place to be associated with the uploads. * @param place The place to be associated with the uploads.
* @param uploadItemIndex Index of the uploadItem whose detected place has been confirmed
*/ */
@Override @Override
public void onUserConfirmedUploadIsOfPlace(final Place place) { public void onUserConfirmedUploadIsOfPlace(final Place place, final int uploadItemIndex) {
final List<UploadItem> uploads = repository.getUploads(); final UploadItem uploadItem = repository.getUploads().get(uploadItemIndex);
for (final UploadItem uploadItem : uploads) {
uploadItem.setPlace(place); uploadItem.setPlace(place);
final List<UploadMediaDetail> uploadMediaDetails = uploadItem.getUploadMediaDetails(); final List<UploadMediaDetail> uploadMediaDetails = uploadItem.getUploadMediaDetails();
// Update UploadMediaDetail object for this UploadItem // Update UploadMediaDetail object for this UploadItem
uploadMediaDetails.set(0, new UploadMediaDetail(place)); uploadMediaDetails.set(0, new UploadMediaDetail(place));
}
// Now that all UploadItems and their associated UploadMediaDetail objects have been updated, // Now that the UploadItem and its associated UploadMediaDetail objects have been updated,
// update the view with the modified media details of the first upload item // update the view with the modified media details of the first upload item
view.updateMediaDetails(uploads.get(0).getUploadMediaDetails()); view.updateMediaDetails(uploadMediaDetails);
UploadActivity.setUploadIsOfAPlace(true); UploadActivity.setUploadIsOfAPlace(true);
} }

View file

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

View file

@ -121,6 +121,7 @@ class UploadWorker(
private var notificationFinishingTitle: String?, private var notificationFinishingTitle: String?,
var contribution: Contribution?, var contribution: Contribution?,
) { ) {
@SuppressLint("MissingPermission")
fun onProgress( fun onProgress(
transferred: Long, transferred: Long,
total: Long, total: Long,
@ -175,6 +176,7 @@ class UploadWorker(
.setProgress(100, 0, true) .setProgress(100, 0, true)
.setOngoing(true) .setOngoing(true)
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
try { try {
var totalUploadsStarted = 0 var totalUploadsStarted = 0
@ -298,7 +300,7 @@ class UploadWorker(
* Upload the contribution * Upload the contribution
* @param contribution * @param contribution
*/ */
@SuppressLint("StringFormatInvalid", "CheckResult") @SuppressLint("StringFormatInvalid", "CheckResult", "MissingPermission")
private suspend fun uploadContribution(contribution: Contribution) { private suspend fun uploadContribution(contribution: Contribution) {
if (contribution.localUri == null || contribution.localUri.path == null) { if (contribution.localUri == null || contribution.localUri.path == null) {
Timber.e("""upload: ${contribution.media.filename} failed, file path is null""") Timber.e("""upload: ${contribution.media.filename} failed, file path is null""")
@ -439,7 +441,7 @@ class UploadWorker(
username, username,
) )
CommonsApplication CommonsApplication
.instance!! .instance
.clearApplicationData(appContext, logoutListener) .clearApplicationData(appContext, logoutListener)
} }
} }
@ -479,8 +481,8 @@ class UploadWorker(
) )
if (null != revisionID) { if (null != revisionID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val place = placesRepository.fetchPlace(wikiDataPlace.id); val place = placesRepository.fetchPlace(wikiDataPlace.id)
place.name = wikiDataPlace.name; place.name = wikiDataPlace.name
place.pic = HOME_URL + uploadResult.createCanonicalFileName() place.pic = HOME_URL + uploadResult.createCanonicalFileName()
placesRepository placesRepository
.save(place) .save(place)
@ -566,12 +568,18 @@ class UploadWorker(
sequenceFileName = sequenceFileName =
if (fileName.indexOf('.') == -1) { if (fileName.indexOf('.') == -1) {
"$fileName #$randomHash" // Append the random hash in parentheses if no file extension is present
"$fileName ($randomHash)"
} else { } else {
val regex = val regex =
Pattern.compile("^(.*)(\\..+?)$") Pattern.compile("^(.*)(\\..+?)$")
val regexMatcher = regex.matcher(fileName) val regexMatcher = regex.matcher(fileName)
regexMatcher.replaceAll("$1 #$randomHash") // Append the random hash in parentheses before the file extension
if (regexMatcher.find()) {
"${regexMatcher.group(1)} ($randomHash)${regexMatcher.group(2)}"
} else {
"$fileName ($randomHash)"
}
} }
} }
return sequenceFileName!! return sequenceFileName!!
@ -581,7 +589,7 @@ class UploadWorker(
* Notify that the current upload has succeeded * Notify that the current upload has succeeded
* @param contribution * @param contribution
*/ */
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid", "MissingPermission")
private fun showSuccessNotification(contribution: Contribution) { private fun showSuccessNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
contribution.state = Contribution.STATE_COMPLETED contribution.state = Contribution.STATE_COMPLETED
@ -606,7 +614,7 @@ class UploadWorker(
* Notify that the current upload has failed * Notify that the current upload has failed
* @param contribution * @param contribution
*/ */
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid", "MissingPermission")
private fun showFailedNotification(contribution: Contribution) { private fun showFailedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java)) currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
@ -626,7 +634,7 @@ class UploadWorker(
) )
} }
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid", "MissingPermission")
private fun showInvalidLoginNotification(contribution: Contribution) { private fun showInvalidLoginNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
currentNotification currentNotification
@ -648,7 +656,7 @@ class UploadWorker(
/** /**
* Shows a notification for a failed contribution upload. * Shows a notification for a failed contribution upload.
*/ */
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid", "MissingPermission")
private fun showErrorNotification(contribution: Contribution) { private fun showErrorNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
currentNotification currentNotification
@ -671,6 +679,7 @@ class UploadWorker(
* Notify that the current upload is paused * Notify that the current upload is paused
* @param contribution * @param contribution
*/ */
@SuppressLint("MissingPermission")
private fun showPausedNotification(contribution: Contribution) { private fun showPausedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
@ -695,6 +704,7 @@ class UploadWorker(
* Notify that the current upload is cancelled * Notify that the current upload is cancelled
* @param contribution * @param contribution
*/ */
@SuppressLint("MissingPermission")
private fun showCancelledNotification(contribution: Contribution) { private fun showCancelledNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle val displayTitle = contribution.media.displayTitle
currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java)) currentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))

View file

@ -55,7 +55,7 @@ class CustomSelectorUtils {
val uploadableFile = PickedFiles.pickedExistingPicture(context, image.uri) val uploadableFile = PickedFiles.pickedExistingPicture(context, image.uri)
val exifInterface: ExifInterface? = val exifInterface: ExifInterface? =
try { try {
ExifInterface(uploadableFile.file!!) ExifInterface(uploadableFile.file)
} catch (e: IOException) { } catch (e: IOException) {
Timber.e(e) Timber.e(e)
null null

View file

@ -62,6 +62,6 @@ object DownloadUtils {
) { ) {
val systemService = val systemService =
activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
systemService?.enqueue(req) systemService.enqueue(req)
} }
} }

Some files were not shown because too many files have changed in this diff Show more