diff --git a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt index 330792fa7..75c4ac26d 100644 --- a/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/auth/LoginActivity.kt @@ -146,7 +146,7 @@ class LoginActivity : AccountAuthenticatorActivity() { loginTwoFactor.removeTextChangedListener(textWatcher) } delegate.onDestroy() - loginClient?.cancel() + loginClient.cancel() binding = null super.onDestroy() } @@ -182,34 +182,34 @@ class LoginActivity : AccountAuthenticatorActivity() { override fun onSaveInstanceState(outState: Bundle) { // 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) { - outState.putBoolean(saveProgressDailog, true) + outState.putBoolean(SAVE_PROGRESS_DIALOG, true) } else { - outState.putBoolean(saveProgressDailog, false) + outState.putBoolean(SAVE_PROGRESS_DIALOG, false) } outState.putString( - saveErrorMessage, + SAVE_ERROR_MESSAGE, binding!!.errorMessage.text.toString() ) //Save the errorMessage outState.putString( - saveUsername, + SAVE_USERNAME, binding!!.loginUsername.text.toString() ) // Save the username outState.putString( - savePassword, + SAVE_PASSWORD, binding!!.loginPassword.text.toString() ) // Save the password } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - binding!!.loginUsername.setText(savedInstanceState.getString(saveUsername)) - binding!!.loginPassword.setText(savedInstanceState.getString(savePassword)) - if (savedInstanceState.getBoolean(saveProgressDailog)) { + binding!!.loginUsername.setText(savedInstanceState.getString(SAVE_USERNAME)) + binding!!.loginPassword.setText(savedInstanceState.getString(SAVE_PASSWORD)) + if (savedInstanceState.getBoolean(SAVE_PROGRESS_DIALOG)) { performLogin() } - val errorMessage = savedInstanceState.getString(saveErrorMessage) + val errorMessage = savedInstanceState.getString(SAVE_ERROR_MESSAGE) if (sessionManager.isUserLoggedIn) { showMessage(R.string.login_success, R.color.primaryDarkColor) } else { @@ -396,9 +396,9 @@ class LoginActivity : AccountAuthenticatorActivity() { fun startYourself(context: Context) = context.startActivity(Intent(context, LoginActivity::class.java)) - const val saveProgressDailog: String = "ProgressDailog_state" - const val saveErrorMessage: String = "errorMessage" - const val saveUsername: String = "username" - const val savePassword: String = "password" + const val SAVE_PROGRESS_DIALOG: String = "ProgressDialog_state" + const val SAVE_ERROR_MESSAGE: String = "errorMessage" + const val SAVE_USERNAME: String = "username" + const val SAVE_PASSWORD: String = "password" } } diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java index 281248ca4..ca7dd3f3b 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarkListRootFragment.java @@ -2,18 +2,17 @@ package fr.free.nrw.commons.bookmarks; import android.content.Context; import android.os.Bundle; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; -import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import fr.free.nrw.commons.Media; 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.locations.BookmarkLocationsFragment; 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 java.util.ArrayList; import java.util.Iterator; +import timber.log.Timber; public class BookmarkListRootFragment extends CommonsDaggerSupportFragment implements FragmentManager.OnBackStackChangedListener, @@ -48,14 +48,21 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple String title = bundle.getString("categoryName"); int order = bundle.getInt("order"); final int orderItem = bundle.getInt("orderItem"); - if (order == 0) { - listFragment = new BookmarkPicturesFragment(); - } else { - listFragment = new BookmarkLocationsFragment(); + + switch (order){ + case 0: listFragment = new BookmarkPicturesFragment(); + break; + + case 1: listFragment = new BookmarkLocationsFragment(); + break; + + case 3: listFragment = new BookmarkCategoriesFragment(); + break; + } if(orderItem == 2) { listFragment = new BookmarkItemsFragment(); } - } + Bundle featuredArguments = new Bundle(); featuredArguments.putString("categoryName", title); listFragment.setArguments(featuredArguments); @@ -129,7 +136,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple @Override public void onMediaClicked(int position) { - Log.d("deneme8", "on media clicked"); + Timber.d("on media clicked"); /*container.setVisibility(View.VISIBLE); ((BookmarkFragment)getParentFragment()).tabLayout.setVisibility(View.GONE); mediaDetails = new MediaDetailPagerFragment(false, true, position); @@ -237,7 +244,7 @@ public class BookmarkListRootFragment extends CommonsDaggerSupportFragment imple @Override 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); ((BookmarkFragment) getParentFragment()).binding.tabLayout.setVisibility(View.GONE); mediaDetails = MediaDetailPagerFragment.newInstance(false, true); diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java index ea3a9a453..f0620032a 100644 --- a/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/BookmarksPagerAdapter.java @@ -49,6 +49,13 @@ public class BookmarksPagerAdapter extends FragmentPagerAdapter { new BookmarkListRootFragment(locationBundle, this), 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(); } diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt new file mode 100644 index 000000000..71a2d1ec9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesDao.kt @@ -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> + +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt new file mode 100644 index 000000000..ef5bc613d --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarkCategoriesFragment.kt @@ -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" + ) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt new file mode 100644 index 000000000..ab679611f --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/bookmarks/category/BookmarksCategoryModal.kt @@ -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 +) diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt index ba1fcfdae..a42d26fd6 100644 --- a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsActivity.kt @@ -7,8 +7,12 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.activity.viewModels import androidx.fragment.app.Fragment 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.R 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.media.MediaDetailPagerFragment 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 + @Inject + lateinit var categoryViewModelFactory: CategoryDetailsViewModel.ViewModelFactory + + private val viewModel: CategoryDetailsViewModel by viewModels { categoryViewModelFactory } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -53,6 +64,15 @@ class CategoryDetailsActivity : BaseActivity(), supportActionBar?.setDisplayHomeAsUpEnabled(true) setTabs() setPageTitle() + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED){ + viewModel.bookmarkState.collect { + invalidateOptionsMenu() + } + } + } + } /** @@ -73,6 +93,8 @@ class CategoryDetailsActivity : BaseActivity(), categoriesMediaFragment.arguments = arguments subCategoryListFragment.arguments = arguments parentCategoriesFragment.arguments = arguments + + viewModel.onCheckIfBookmarked(categoryName!!) } fragmentList.add(categoriesMediaFragment) titleList.add("MEDIA") @@ -181,6 +203,14 @@ class CategoryDetailsActivity : BaseActivity(), Utils.handleWebUrl(this, Uri.parse(title.canonicalUri)) true } + + R.id.menu_bookmark_current_category -> { + categoryName?.let { + viewModel.onBookmarkClick(categoryName = it) + } + true + } + android.R.id.home -> { onBackPressed() 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. * If condition is called when mediaDetailFragment is opened. diff --git a/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt new file mode 100644 index 000000000..a50f25669 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/category/CategoryDetailsViewModel.kt @@ -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 create(modelClass: Class): T = + if (modelClass.isAssignableFrom(CategoryDetailsViewModel::class.java)) { + CategoryDetailsViewModel(bookmarkCategoriesDao) as T + } else { + throw IllegalArgumentException("Unknown class name") + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java deleted file mode 100644 index c1c8fac18..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.java +++ /dev/null @@ -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(); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt new file mode 100644 index 000000000..378a98893 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/BackgroundPoolExceptionHandler.kt @@ -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() + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java deleted file mode 100644 index 80931b1c1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.java +++ /dev/null @@ -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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt new file mode 100644 index 000000000..0efe057f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionAwareThreadPoolExecutor.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java deleted file mode 100644 index 38690305a..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.concurrency; - -import androidx.annotation.NonNull; - -public interface ExceptionHandler { - void onException(@NonNull Throwable t); -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt new file mode 100644 index 000000000..6b3d2a0f7 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ExceptionHandler.kt @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.concurrency + +interface ExceptionHandler { + + fun onException(t: Throwable) + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java deleted file mode 100644 index f057f61b2..000000000 --- a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.java +++ /dev/null @@ -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 ScheduledFuture schedule(Callable 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); - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt new file mode 100644 index 000000000..46138d676 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/concurrency/ThreadPoolService.kt @@ -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 schedule(callable: Callable, time: Long, timeUnit: TimeUnit): ScheduledFuture { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt index 0a757619f..84ce0eb9c 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/Contribution.kt @@ -63,13 +63,13 @@ data class Contribution constructor( Media( formatCaptions(item.uploadMediaDetails), categories, - item.fileName, + item.filename, formatDescriptions(item.uploadMediaDetails), sessionManager.userName, sessionManager.userName, ), localUri = item.mediaUri, - decimalCoords = item.gpsCoords.decimalCoords, + decimalCoords = item.gpsCoords?.decimalCoords, dateCreatedSource = "", depictedItems = depictedItems, wikidataPlace = from(item.place), diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt index 3f7bffe91..b5075a21e 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionBoundaryCallback.kt @@ -2,7 +2,6 @@ package fr.free.nrw.commons.contributions import androidx.paging.PagedList.BoundaryCallback 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.media.MediaClient import io.reactivex.Scheduler @@ -31,10 +30,7 @@ class ContributionBoundaryCallback * network */ override fun onZeroItemsLoaded() { - if (sessionManager.userName != null) { - mediaClient.resetUserNameContinuation(sessionManager.userName!!) - } - fetchContributions() + refreshList() } /** @@ -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) { userName ?.let { userName -> @@ -65,12 +77,15 @@ class ContributionBoundaryCallback Contribution(media = media, state = Contribution.STATE_COMPLETED) } }.subscribeOn(ioThreadScheduler) - .subscribe(::saveContributionsToDB) { error: Throwable -> + .subscribe({ list -> + saveContributionsToDB(list, onRefreshFinish) + },{ error -> + onRefreshFinish() Timber.e( "Failed to fetch contributions: %s", error.message, ) - } + }) }?.let { compositeDisposable.add( it, @@ -83,13 +98,16 @@ class ContributionBoundaryCallback /** * Saves the contributions the the local DB + * + * @param onRefreshFinish callback to invoke when successfully saved to DB. */ - private fun saveContributionsToDB(contributions: List) { + private fun saveContributionsToDB(contributions: List, onRefreshFinish: () -> Unit) { compositeDisposable.add( repository .save(contributions) .subscribeOn(ioThreadScheduler) .subscribe { longs: List? -> + onRefreshFinish() repository["last_fetch_timestamp"] = System.currentTimeMillis() }, ) diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java index 58bd2783d..0d0a19436 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListContract.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.contributions; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import fr.free.nrw.commons.BasePresenter; /** @@ -17,5 +18,8 @@ public class ContributionsListContract { } public interface UserActionListener extends BasePresenter { + + void refreshList(SwipeRefreshLayout swipeRefreshLayout); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java index 509d1eb95..df65a91cc 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListFragment.java @@ -191,6 +191,15 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl 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(); } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java index 735ff63d4..100c8be03 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/ContributionsListPresenter.java @@ -8,14 +8,15 @@ import androidx.paging.DataSource; import androidx.paging.DataSource.Factory; import androidx.paging.LivePagedListBuilder; import androidx.paging.PagedList; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionListener; -import fr.free.nrw.commons.di.CommonsApplicationModule; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; -import java.util.Arrays; import java.util.Collections; import javax.inject.Inject; import javax.inject.Named; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; /** * The presenter class for Contributions @@ -95,4 +96,17 @@ public class ContributionsListPresenter implements UserActionListener { 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; + }); + } } diff --git a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java index c9b55a83c..0f18c300b 100644 --- a/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java +++ b/app/src/main/java/fr/free/nrw/commons/contributions/SetWallpaperWorker.java @@ -5,14 +5,11 @@ import android.app.NotificationManager; import android.app.WallpaperManager; import android.content.Context; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; -import androidx.work.Data; import androidx.work.Worker; import androidx.work.WorkerParameters; 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.ImageRequestBuilder; import fr.free.nrw.commons.R; -import java.io.IOException; import timber.log.Timber; public class SetWallpaperWorker extends Worker { diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt index 39454c68d..9a949b1cf 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -42,6 +42,7 @@ object FolderDeletionHelper { AlertDialog.Builder(context) .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) + .setCancelable(false) .setMessage( context.getString( R.string.custom_selector_confirm_deletion_message, diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 6c6d7e53f..52b615175 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -250,7 +250,7 @@ class CustomSelectorActivity : val selectedImages: ArrayList = result.data!! .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! - viewModel?.selectedImages?.value = selectedImages + viewModel.selectedImages?.value = selectedImages } } @@ -268,6 +268,7 @@ class CustomSelectorActivity : */ private fun showWelcomeDialog() { val dialog = Dialog(this) + dialog.setCancelable(false) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.setContentView(R.layout.custom_selector_info_dialog) (dialog.findViewById(R.id.btn_ok) as Button).setOnClickListener { dialog.dismiss() } @@ -683,6 +684,7 @@ class CustomSelectorActivity : */ private fun displayUploadLimitWarning() { val dialog = Dialog(this) + dialog.setCancelable(false) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.setContentView(R.layout.custom_selector_limit_dialog) (dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener { dialog.dismiss() } diff --git a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt index 83f7687d4..1377ae281 100644 --- a/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt +++ b/app/src/main/java/fr/free/nrw/commons/data/DBOpenHelper.kt @@ -18,7 +18,7 @@ class DBOpenHelper( companion object { private const val DATABASE_NAME = "commons.db" - private const val DATABASE_VERSION = 20 + private const val DATABASE_VERSION = 21 const val CONTRIBUTIONS_TABLE = "contributions" private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS %s" } diff --git a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt index 71947fa1a..0c34bbdec 100644 --- a/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt +++ b/app/src/main/java/fr/free/nrw/commons/db/AppDatabase.kt @@ -3,6 +3,8 @@ package fr.free.nrw.commons.db import androidx.room.Database import androidx.room.RoomDatabase 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.ContributionDao import fr.free.nrw.commons.customselector.database.NotForUploadStatus @@ -21,8 +23,8 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao * */ @Database( - entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], - version = 18, + entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class, BookmarksCategoryModal::class], + version = 19, exportSchema = false, ) @TypeConverters(Converters::class) @@ -38,4 +40,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun NotForUploadStatusDao(): NotForUploadStatusDao abstract fun ReviewDao(): ReviewDao + + abstract fun bookmarkCategoriesDao(): BookmarkCategoriesDao } diff --git a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt index 32c1e5829..3c6ad8653 100644 --- a/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/description/DescriptionEditActivity.kt @@ -240,7 +240,7 @@ class DescriptionEditActivity : applicationContext, media, updatedWikiText, - )?.subscribeOn(Schedulers.io()) + ).subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(Consumer { s: Boolean? -> Timber.d("Descriptions are added.") }) ?.let { diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt index b195674a9..58d9039d5 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.kt @@ -15,6 +15,7 @@ import dagger.Provides import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.R 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.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatusDao @@ -221,6 +222,10 @@ open class CommonsApplicationModule(private val applicationContext: Context) { fun providesReviewDao(appDatabase: AppDatabase): ReviewDao = appDatabase.ReviewDao() + @Provides + fun providesBookmarkCategoriesDao (appDatabase: AppDatabase): BookmarkCategoriesDao = + appDatabase.bookmarkCategoriesDao() + @Provides fun providesContentResolver(context: Context): ContentResolver = context.contentResolver diff --git a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt index bfdb90181..0ef34e355 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt +++ b/app/src/main/java/fr/free/nrw/commons/di/FragmentBuilderModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.android.ContributesAndroidInjector import fr.free.nrw.commons.bookmarks.BookmarkFragment 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.locations.BookmarkLocationsFragment import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesFragment @@ -97,6 +98,9 @@ abstract class FragmentBuilderModule { @ContributesAndroidInjector(modules = [BookmarkItemsFragmentModule::class]) abstract fun bindBookmarkItemListFragment(): BookmarkItemsFragment + @ContributesAndroidInjector + abstract fun bindBookmarkCategoriesListFragment(): BookmarkCategoriesFragment + @ContributesAndroidInjector abstract fun bindReviewOutOfContextFragment(): ReviewImageFragment diff --git a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java index 76627ebcf..fd1ea1f28 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/map/ExploreMapFragment.java @@ -17,6 +17,7 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.location.Location; import android.location.LocationManager; +import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.text.Html; @@ -167,7 +168,11 @@ public class ExploreMapFragment extends CommonsDaggerSupportFragment public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); 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(); locationPermissionsHelper = new LocationPermissionsHelper(getActivity(),locationManager,this); if (presenter == null) { diff --git a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java index 0db1e5539..588f3a25f 100644 --- a/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/explore/recentsearches/RecentSearchesFragment.java @@ -67,6 +67,7 @@ public class RecentSearchesFragment extends CommonsDaggerSupportFragment { .setPositiveButton(android.R.string.yes, (dialog, which) -> setDeleteRecentPositiveButton(context, dialog)) .setNegativeButton(android.R.string.no, null) + .setCancelable(false) .create() .show(); } @@ -94,6 +95,7 @@ public class RecentSearchesFragment extends CommonsDaggerSupportFragment { .setPositiveButton(getString(R.string.delete).toUpperCase(Locale.ROOT), ((dialog, which) -> setDeletePositiveButton(context, dialog, position))) .setNegativeButton(android.R.string.cancel, null) + .setCancelable(false) .create() .show(); } diff --git a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt index 93cdab944..6d68fb66d 100644 --- a/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt +++ b/app/src/main/java/fr/free/nrw/commons/feedback/FeedbackDialog.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.feedback import android.app.Dialog import android.content.Context +import android.os.Build import android.os.Bundle import android.text.Html import android.text.Spanned @@ -26,11 +27,13 @@ class FeedbackDialog( private val onFeedbackSubmitCallback: OnFeedbackSubmitCallback) : Dialog(context) { private var _binding: DialogFeedbackBinding? = null private val binding get() = _binding!! - // TODO("Remove Deprecation") Issue : #6002 - // 'fromHtml(String!): Spanned!' is deprecated. Deprecated in Java - @Suppress("DEPRECATION") - private var feedbackDestinationHtml: Spanned = Html.fromHtml( - context.getString(R.string.feedback_destination_note)) + // Refactored to handle deprecation for Html.fromHtml() + private var feedbackDestinationHtml: Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(context.getString(R.string.feedback_destination_note), Html.FROM_HTML_MODE_LEGACY) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(context.getString(R.string.feedback_destination_note)) + } public override fun onCreate(savedInstanceState: Bundle?) { @@ -43,6 +46,7 @@ class FeedbackDialog( // 'SOFT_INPUT_ADJUST_RESIZE: Int' is deprecated. Deprecated in Java @Suppress("DEPRECATION") window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + binding.btnCancel.setOnClickListener { dismiss() } binding.btnSubmitFeedback.setOnClickListener { try { submitFeedback() diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt index 6bf8a1061..2ed573740 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -263,17 +263,8 @@ object FilePicker : Constants { ) { if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { try { - val photoPath = result.data?.data - val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!) - callbacks.onImagesPicked( - singleFileList(photoFile), - ImageSource.DOCUMENTS, - restoreType(activity) - ) - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) - } + val files = getFilesFromGalleryPictures(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.DOCUMENTS, restoreType(activity)) } catch (e: Exception) { e.printStackTrace() callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity)) diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt index 0cf21cc02..5fe4c288b 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt @@ -13,7 +13,7 @@ class MimeTypeMapWrapper { ) @JvmStatic - fun getExtensionFromMimeType(mimeType: String): String? { + fun getExtensionFromMimeType(mimeType: String?): String? { val result = sMimeTypeToExtensionMap[mimeType] if (result != null) { return result diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt index 1398e7785..d8109cb3d 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt @@ -10,6 +10,7 @@ import android.os.Parcelable import androidx.exifinterface.media.ExifInterface import fr.free.nrw.commons.upload.FileUtils +import fr.free.nrw.commons.upload.ImageCoordinates import java.io.File import java.io.IOException import java.util.Date @@ -87,9 +88,7 @@ class UploadableFile : Parcelable { fun hasLocation(): Boolean { return try { val exif = ExifInterface(file.absolutePath) - val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) - val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE) - latitude != null && longitude != null + ImageCoordinates(exif, null).imageCoordsExists } catch (e: IOException) { Timber.tag("UploadableFile").d(e) false diff --git a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt index 5c6c55f1a..3cfb92350 100644 --- a/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt +++ b/app/src/main/java/fr/free/nrw/commons/logging/FileLoggingTree.kt @@ -5,7 +5,6 @@ import android.util.Log import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.util.Locale import java.util.concurrent.Executor import ch.qos.logback.classic.LoggerContext diff --git a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java index 8f52b1ced..545e96624 100644 --- a/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/media/MediaDetailPagerFragment.java @@ -31,6 +31,7 @@ import com.google.android.material.snackbar.Snackbar; import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.Media; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.bookmarks.models.Bookmark; import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesContentProvider; @@ -211,6 +212,13 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple snackbar.show(); updateBookmarkState(item); 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: Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); @@ -283,6 +291,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple builder.setItems(R.array.report_violation_options, (dialog, which) -> { sendReportEmail(media, values[which]); }); + builder.setNegativeButton(R.string.cancel, (dialog, which) -> {}); builder.setCancelable(false); builder.show(); } @@ -390,6 +399,7 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple if (m != null) { // 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_copy_link).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_bookmark_current_image).setEnabled(true).setVisible(true); @@ -423,6 +433,8 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple case Contribution.STATE_QUEUED: menu.findItem(R.id.menu_browser_current_image).setEnabled(false) .setVisible(false); + menu.findItem(R.id.menu_copy_link).setEnabled(false) + .setVisible(false); menu.findItem(R.id.menu_share_current_image).setEnabled(false) .setVisible(false); menu.findItem(R.id.menu_download_current_image).setEnabled(false) @@ -440,6 +452,8 @@ public class MediaDetailPagerFragment extends CommonsDaggerSupportFragment imple } else { menu.findItem(R.id.menu_browser_current_image).setEnabled(false) .setVisible(false); + menu.findItem(R.id.menu_copy_link).setEnabled(false) + .setVisible(false); menu.findItem(R.id.menu_share_current_image).setEnabled(false) .setVisible(false); menu.findItem(R.id.menu_download_current_image).setEnabled(false) diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt index 71ea1d692..291c834bd 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/OkHttpJsonApiClient.kt @@ -2,6 +2,7 @@ package fr.free.nrw.commons.mwapi import android.text.TextUtils import com.google.gson.Gson +import com.google.gson.JsonParser import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.campaigns.CampaignResponseDTO 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.nearby.Place 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.PlaceBindings import fr.free.nrw.commons.profile.achievements.FeaturedImages @@ -175,7 +177,7 @@ class OkHttpJsonApiClient @Inject constructor( .build() val response: Response = okHttpClient.newCall(request).execute() if (response.body != null && response.isSuccessful) { - val json: String = response.body!!.string() ?: return@fromCallable null + val json: String = response.body!!.string() try { return@fromCallable gson.fromJson( json, @@ -330,36 +332,130 @@ class OkHttpJsonApiClient @Inject constructor( 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) fun getNearbyPlaces( - screenTopRight: LatLng, - screenBottomLeft: LatLng, language: String, + queryParams: NearbyQueryParams, language: String, shouldQueryForMonuments: Boolean, customQuery: String? ): List? { Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) + val locale = Locale.ROOT; val wikidataQuery: String = if (customQuery != null) { - customQuery - } else if (!shouldQueryForMonuments) { - FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq") - } else { - FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq") + 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 + 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()!! .newBuilder() - .addQueryParameter("query", query) + .addQueryParameter("query", wikidataQuery) .addQueryParameter("format", "json") val request: Request = Request.Builder() diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt index a79df3e15..3f7a196fe 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetFragment.kt @@ -161,7 +161,10 @@ class MoreBottomSheetFragment : BottomSheetDialogFragment() { override fun onFeedbackSubmit(feedback: Feedback) { uploadFeedback(feedback) } - }).show() + }).apply { + setCancelable(false) + show() + } } /** diff --git a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt index 96baf9e5e..769f524b3 100644 --- a/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/navtab/MoreBottomSheetLoggedOutFragment.kt @@ -94,6 +94,7 @@ class MoreBottomSheetLoggedOutFragment : BottomSheetDialogFragment() { .setMessage(R.string.feedback_sharing_data_alert) .setCancelable(false) .setPositiveButton(R.string.ok) { _, _ -> sendFeedback() } + .setNegativeButton(R.string.cancel){_,_ -> } .show() } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/MarkerPlaceGroup.java b/app/src/main/java/fr/free/nrw/commons/nearby/MarkerPlaceGroup.java index 691f60f6a..c2474adc3 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/MarkerPlaceGroup.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/MarkerPlaceGroup.java @@ -20,4 +20,8 @@ public class MarkerPlaceGroup { public boolean getIsBookmarked() { return isBookmarked; } + + public void setIsBookmarked(boolean isBookmarked) { + this.isBookmarked = isBookmarked; + } } \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java index 7bb311961..403813519 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyController.java @@ -20,8 +20,8 @@ import timber.log.Timber; public class NearbyController extends MapController { - private static final int MAX_RESULTS = 1000; private final NearbyPlaces nearbyPlaces; + public static final int MAX_RESULTS = 1000; public static double currentLocationSearchRadius = 10.0; //in kilometers 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 @@ -196,8 +196,9 @@ public class NearbyController extends MapController { return null; } - List places = nearbyPlaces.getFromWikidataQuery(screenTopRight, screenBottomLeft, - Locale.getDefault().getLanguage(), shouldQueryForMonuments, customQuery); + List places = nearbyPlaces.getFromWikidataQuery(currentLatLng, screenTopRight, + screenBottomLeft, Locale.getDefault().getLanguage(), shouldQueryForMonuments, + customQuery); if (null != places && places.size() > 0) { LatLng[] boundaryCoordinates = { diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java index 46f0a2a9e..caae8ee45 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/NearbyPlaces.java @@ -1,6 +1,8 @@ package fr.free.nrw.commons.nearby; +import android.location.Location; import androidx.annotation.Nullable; +import fr.free.nrw.commons.nearby.model.NearbyQueryParams; import java.io.IOException; import java.util.Collections; 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 * 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 screenBottomLeft The bottom left corner of the screen (latitude, longitude). * @param lang The language for the query. @@ -111,13 +114,70 @@ public class NearbyPlaces { * @throws Exception If an error occurs during the retrieval process. */ public List getFromWikidataQuery( + final fr.free.nrw.commons.location.LatLng centerPoint, final fr.free.nrw.commons.location.LatLng screenTopRight, final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang, final boolean shouldQueryForMonuments, @Nullable final String customQuery) throws Exception { - return okHttpJsonApiClient - .getNearbyPlaces(screenTopRight, screenBottomLeft, lang, shouldQueryForMonuments, - customQuery); + if (customQuery != null) { + return okHttpJsonApiClient + .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); } /** diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java index 7732669bc..21dd14131 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/Place.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/Place.java @@ -5,6 +5,7 @@ import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.room.Embedded; import androidx.room.Entity; import androidx.room.PrimaryKey; import fr.free.nrw.commons.location.LatLng; @@ -24,6 +25,7 @@ public class Place implements Parcelable { public String name; private Label label; private String longDescription; + @Embedded public LatLng location; @PrimaryKey @NonNull public String entityID; diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt index a4ea3cd5b..7156568b6 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceAdapterDelegate.kt @@ -5,10 +5,12 @@ import android.view.View import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE +import android.widget.RelativeLayout import androidx.activity.result.ActivityResultLauncher import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import fr.free.nrw.commons.R @@ -39,12 +41,18 @@ fun placeAdapterDelegate( showOrHideAndScrollToIfLast() 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) { nearbyButtonLayout.buttonLayout.visibility = GONE } else if (hasFocus && !nearbyButtonLayout.buttonLayout.isShown) { - showOrHideAndScrollToIfLast() - onItemClick?.invoke(item) + if (bottomSheetBehavior?.state != BottomSheetBehavior.STATE_HIDDEN) { + showOrHideAndScrollToIfLast() + onItemClick?.invoke(item) + } } } nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult) } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java index 9e4292114..269384ffa 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlaceDao.java @@ -5,6 +5,7 @@ import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; import io.reactivex.Completable; +import java.util.List; /** * 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") 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 fetchPlaces(double latBegin, double lngBegin, + double latEnd, double lngEnd); + /** * Saves a Place object asynchronously into the database. */ diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java index 86a57eadc..2d8c2733a 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesLocalDataSource.java @@ -1,7 +1,11 @@ package fr.free.nrw.commons.nearby; +import fr.free.nrw.commons.location.LatLng; import io.reactivex.Completable; +import java.util.ArrayList; +import java.util.List; import javax.inject.Inject; +import timber.log.Timber; /** * The LocalDataSource class for Places @@ -26,6 +30,81 @@ public class PlacesLocalDataSource { 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 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 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 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. * diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java index 846e54fac..c2edfe355 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/PlacesRepository.java @@ -4,6 +4,7 @@ import fr.free.nrw.commons.contributions.Contribution; import fr.free.nrw.commons.location.LatLng; import io.reactivex.Completable; import io.reactivex.schedulers.Schedulers; +import java.util.List; import javax.inject.Inject; /** @@ -39,6 +40,17 @@ public class PlacesRepository { 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 fetchPlaces(final LatLng mapBottomLeft, final LatLng mapTopRight) { + return localDataSource.fetchPlaces(mapBottomLeft, mapTopRight); + } + /** * Clears the Nearby cache on an IO thread. * diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt index 299ac4b6e..e5196bee8 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt +++ b/app/src/main/java/fr/free/nrw/commons/nearby/WikidataFeedback.kt @@ -94,6 +94,7 @@ class WikidataFeedback : BaseActivity() { }, { throwable: Throwable? -> Timber.e(throwable!!) }) + finish() } } } diff --git a/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java b/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java index bcf8d8421..e46e95353 100644 --- a/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java +++ b/app/src/main/java/fr/free/nrw/commons/nearby/contract/NearbyParentFragmentContract.java @@ -2,11 +2,13 @@ package fr.free.nrw.commons.nearby.contract; import android.content.Context; import androidx.annotation.Nullable; +import androidx.lifecycle.LifecycleCoroutineScope; import fr.free.nrw.commons.BaseMarker; 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.nearby.Label; +import fr.free.nrw.commons.nearby.MarkerPlaceGroup; import fr.free.nrw.commons.nearby.Place; import java.util.List; @@ -16,6 +18,8 @@ public interface NearbyParentFragmentContract { boolean isNetworkConnectionEstablished(); + void updateSnackbar(boolean offlinePinsShown); + void listOptionMenuItemClicked(); void populatePlaces(LatLng currentLatLng); @@ -68,7 +72,7 @@ public interface NearbyParentFragmentContract { Context getContext(); - void updateMapMarkers(List BaseMarkers); + void replaceMarkerOverlays(List markerPlaceGroups); void filterOutAllMarkers(); @@ -89,6 +93,10 @@ public interface NearbyParentFragmentContract { LatLng getMapFocus(); + LatLng getScreenTopRight(); + + LatLng getScreenBottomLeft(); + boolean isAdvancedQueryFragmentVisible(); void showHideAdvancedQueryFragment(boolean shouldShow); @@ -120,12 +128,14 @@ public interface NearbyParentFragmentContract { void filterByMarkerType(List