Merge branch 'main' into Languages-order-preference-#5826-

This commit is contained in:
Nicolas Raoul 2025-01-24 16:03:00 +09:00 committed by GitHub
commit c549144f11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
146 changed files with 5313 additions and 4391 deletions

View file

@ -89,7 +89,7 @@ jobs:
run: bash ./gradlew assembleBetaDebug --stacktrace
- name: Upload betaDebug APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: betaDebugAPK
path: app/build/outputs/apk/beta/debug/app-*.apk
@ -98,7 +98,7 @@ jobs:
run: bash ./gradlew assembleProdDebug --stacktrace
- name: Upload prodDebug APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: prodDebugAPK
path: app/build/outputs/apk/prod/debug/app-*.apk

View file

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

View file

@ -50,6 +50,8 @@ dependencies {
implementation "com.google.android.material:material:1.12.0"
implementation 'com.karumi:dexter:5.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.compose.ui:ui-tooling-preview'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
// Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.11.00')
@ -87,6 +89,8 @@ dependencies {
// Dependency injector
implementation "com.google.dagger:dagger-android:$DAGGER_VERSION"
implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION"
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
kapt "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
annotationProcessor "com.google.dagger:dagger-android-processor:$DAGGER_VERSION"
@ -208,8 +212,8 @@ android {
defaultConfig {
//applicationId 'fr.free.nrw.commons'
versionCode 1040
versionName '5.0.2'
versionCode 1043
versionName '5.1.2'
setProperty("archivesBaseName", "app-commons-v$versionName-" + getBranchName())
minSdkVersion 21
@ -310,6 +314,7 @@ android {
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.org\""
buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.org/wiki/\""
buildConfigField "String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.org/wiki/\""
buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\""
@ -346,6 +351,7 @@ android {
buildConfigField "String", "COMMONS_URL", "\"https://commons.wikimedia.beta.wmflabs.org\""
buildConfigField "String", "WIKIDATA_URL", "\"https://www.wikidata.org\""
buildConfigField "String", "MOBILE_HOME_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "MOBILE_META_URL", "\"https://meta.m.wikimedia.beta.wmflabs.org/wiki/\""
buildConfigField "String", "SIGNUP_LANDING_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Special:CreateAccount&returnto=Main+Page&returntoquery=welcome%3Dyes\""
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\""

View file

@ -55,6 +55,10 @@
android:theme="@style/LightAppTheme"
tools:ignore="GoogleAppIndexingWarning"
tools:replace="android:appComponentFactory">
<activity
android:name=".activity.SingleWebViewActivity"
android:exported="false"
android:label="@string/title_activity_single_web_view" />
<activity
android:name=".nearby.WikidataFeedback"
android:exported="false" />
@ -258,4 +262,4 @@
android:required="false" />
</application>
</manifest>
</manifest>

View file

@ -0,0 +1,209 @@
package fr.free.nrw.commons.activity
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
import javax.inject.Inject
/**
* SingleWebViewActivity is a reusable activity webView based on a given url(initial url) and
* closes itself when a specified success URL is reached to success url.
*/
class SingleWebViewActivity : ComponentActivity() {
@Inject
lateinit var cookieJar: CommonsCookieJar
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(VANISH_ACCOUNT_URL)
val successUrl = intent.getStringExtra(VANISH_ACCOUNT_SUCCESS_URL)
if (url == null || successUrl == null) {
finish()
return
}
ApplicationlessInjection
.getInstance(applicationContext)
.commonsApplicationComponent
.inject(this)
setCookies(url)
enableEdgeToEdge()
setContent {
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier,
title = { Text(getString(R.string.vanish_account)) },
navigationIcon = {
IconButton(
onClick = {
// Close the WebView Activity if the user taps the back button
finish()
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
// TODO("Add contentDescription)
contentDescription = ""
)
}
}
)
},
content = {
WebViewComponent(
url = url,
successUrl = successUrl,
onSuccess = {
// TODO Redirect the user to login screen like we do when the user logout's
finish()
},
modifier = Modifier
.fillMaxSize()
.padding(it)
)
}
)
}
}
/**
* @param url The initial URL which we are loading in the WebView.
* @param successUrl The URL that, when reached, triggers the `onSuccess` callback.
* @param onSuccess A callback that is invoked when the current url of webView is successUrl.
* This is used when we want to close when the webView once a success url is hit.
* @param modifier An optional [Modifier] to customize the layout or appearance of the WebView.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun WebViewComponent(
url: String,
successUrl: String,
onSuccess: () -> Unit,
modifier: Modifier = Modifier
) {
val webView = remember { mutableStateOf<WebView?>(null) }
AndroidView(
modifier = modifier,
factory = {
WebView(it).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
javaScriptCanOpenWindowsAutomatically = true
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
request?.url?.let { url ->
Timber.d("URL Loading: $url")
if (url.toString() == successUrl) {
Timber.d("Success URL detected. Closing WebView.")
onSuccess() // Close the activity
return true
}
return false
}
return false
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
setCookies(url.orEmpty())
}
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(message: ConsoleMessage): Boolean {
Timber.d("Console: ${message.message()} -- From line ${message.lineNumber()} of ${message.sourceId()}")
return true
}
}
loadUrl(url)
}
},
update = {
webView.value = it
}
)
}
/**
* Sets cookies for the given URL using the cookies stored in the `CommonsCookieJar`.
*
* @param url The URL for which cookies need to be set.
*/
private fun setCookies(url: String) {
CookieManager.getInstance().let {
val cookies = cookieJar.loadForRequest(url.toHttpUrl())
for (cookie in cookies) {
it.setCookie(url, cookie.toString())
}
}
}
companion object {
private const val VANISH_ACCOUNT_URL = "VanishAccountUrl"
private const val VANISH_ACCOUNT_SUCCESS_URL = "vanishAccountSuccessUrl"
/**
* Launch the WebViewActivity with the specified URL and success URL.
* @param context The context from which the activity is launched.
* @param url The initial URL to load in the WebView.
* @param successUrl The URL that triggers the WebView to close when matched.
*/
fun showWebView(
context: Context,
url: String,
successUrl: String
) {
val intent = Intent(
context,
SingleWebViewActivity::class.java
).apply {
putExtra(VANISH_ACCOUNT_URL, url)
putExtra(VANISH_ACCOUNT_SUCCESS_URL, successUrl)
}
context.startActivity(intent)
}
}
}

View file

@ -184,32 +184,32 @@ class LoginActivity : AccountAuthenticatorActivity() {
// if progressDialog is visible during the configuration change then store state as true else false so that
// we maintain visibility of progressDialog after configuration change
if (progressDialog != null && progressDialog!!.isShowing) {
outState.putBoolean(saveProgressDialog, true)
outState.putBoolean(SAVE_PROGRESS_DIALOG, true)
} else {
outState.putBoolean(saveProgressDialog, 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(saveProgressDialog)) {
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 saveProgressDialog: String = "ProgressDialog_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"
}
}

View file

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

View file

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

View file

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

View file

@ -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

@ -112,7 +112,7 @@ data class Contribution constructor(
*/
fun formatDescriptions(descriptions: List<UploadMediaDetail>) =
descriptions
.filter { it.descriptionText.isNotEmpty() }
.filter { !it.descriptionText.isNullOrEmpty() }
.joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" }
}

View file

@ -61,16 +61,16 @@ class CoordinateEditHelper @Inject constructor(
/**
* Replaces new coordinates
* @param media to be added
* @param Latitude to be added
* @param Longitude to be added
* @param Accuracy to be added
* @param latitude to be added
* @param longitude to be added
* @param accuracy to be added
* @return Observable<Boolean>
*/
private fun addCoordinates(
media: Media,
Latitude: String,
Longitude: String,
Accuracy: String
latitude: String,
longitude: String,
accuracy: String
): Observable<Boolean>? {
Timber.d("thread is coordinates adding %s", Thread.currentThread().getName())
val summary = "Adding Coordinates"
@ -83,9 +83,9 @@ class CoordinateEditHelper @Inject constructor(
.blockingGet()
}
if (Latitude != null) {
buffer.append("\n{{Location|").append(Latitude).append("|").append(Longitude)
.append("|").append(Accuracy).append("}}")
if (latitude != null) {
buffer.append("\n{{Location|").append(latitude).append("|").append(longitude)
.append("|").append(accuracy).append("}}")
}
val editedLocation = buffer.toString()
@ -141,7 +141,7 @@ class CoordinateEditHelper @Inject constructor(
* @param media to be added
* @param latitude to be added
* @param longitude to be added
* @param Accuracy to be added
* @param accuracy to be added
* @param result to be added
* @return boolean
*/
@ -150,7 +150,7 @@ class CoordinateEditHelper @Inject constructor(
media: Media,
latitude: String,
longitude: String,
Accuracy: String,
accuracy: String,
result: Boolean
): Boolean {
val message: String
@ -160,7 +160,7 @@ class CoordinateEditHelper @Inject constructor(
media.coordinates = fr.free.nrw.commons.location.LatLng(
latitude.toDouble(),
longitude.toDouble(),
Accuracy.toFloat()
accuracy.toFloat()
)
title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title_success)
val coordinatesInMessage = StringBuilder()

View file

@ -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,

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.util.TreeMap
import kotlin.collections.ArrayList
@ -103,6 +104,18 @@ class ImageAdapter(
*/
private var imagePositionAsPerIncreasingOrder = 0
/**
* Stores the number of images currently visible on the screen
*/
private val _currentImagesCount = MutableStateFlow(0)
val currentImagesCount = _currentImagesCount
/**
* Stores whether images are being loaded or not
*/
private val _isLoadingImages = MutableStateFlow(false)
val isLoadingImages = _isLoadingImages
/**
* Coroutine Dispatchers and Scope.
*/
@ -184,8 +197,12 @@ class ImageAdapter(
// If the position is not already visited, that means the position is new then
// finds the next actionable image position from all images
if (!alreadyAddedPositions.contains(position)) {
processThumbnailForActionedImage(holder, position, uploadingContributionList)
processThumbnailForActionedImage(
holder,
position,
uploadingContributionList
)
_isLoadingImages.value = false
// If the position is already visited, that means the image is already present
// inside map, so it will fetch the image from the map and load in the holder
} else {
@ -231,6 +248,7 @@ class ImageAdapter(
position: Int,
uploadingContributionList: List<Contribution>,
) {
_isLoadingImages.value = true
val next =
imageLoader.nextActionableImage(
allImages,
@ -252,6 +270,7 @@ class ImageAdapter(
actionableImagesMap[next] = allImages[next]
alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder)
imagePositionAsPerIncreasingOrder++
_currentImagesCount.value = imagePositionAsPerIncreasingOrder
Glide
.with(holder.image)
.load(allImages[next].uri)
@ -267,6 +286,7 @@ class ImageAdapter(
reachedEndOfFolder = true
notifyItemRemoved(position)
}
_isLoadingImages.value = false
}
/**
@ -372,6 +392,7 @@ class ImageAdapter(
emptyMap: TreeMap<Int, Image>,
uploadedImages: List<Contribution> = ArrayList(),
) {
_isLoadingImages.value = true
allImages = fixedImages
val oldImageList: ArrayList<Image> = images
val newImageList: ArrayList<Image> = ArrayList(newImages)
@ -382,6 +403,7 @@ class ImageAdapter(
reachedEndOfFolder = false
selectedImages = ArrayList()
imagePositionAsPerIncreasingOrder = 0
_currentImagesCount.value = imagePositionAsPerIncreasingOrder
val diffResult =
DiffUtil.calculateDiff(
ImagesDiffCallback(oldImageList, newImageList),
@ -441,6 +463,7 @@ class ImageAdapter(
val entry = iterator.next()
if (entry.value == image) {
imagePositionAsPerIncreasingOrder -= 1
_currentImagesCount.value = imagePositionAsPerIncreasingOrder
iterator.remove()
alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1)
notifyItemRemoved(index)

View file

@ -268,9 +268,10 @@ 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() }
(dialog.findViewById<Button>(R.id.btn_ok))?.setOnClickListener { dialog.dismiss() }
dialog.show()
}
@ -683,10 +684,11 @@ 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() }
(dialog.findViewById(R.id.upload_limit_warning) as TextView).text =
(dialog.findViewById<Button>(R.id.btn_dismiss_limit_warning))?.setOnClickListener { dialog.dismiss() }
(dialog.findViewById<TextView>(R.id.upload_limit_warning))?.text =
resources.getString(
R.string.custom_selector_over_limit_warning,
uploadLimit,

View file

@ -12,8 +12,12 @@ import android.widget.ProgressBar
import android.widget.Switch
import androidx.appcompat.app.AlertDialog
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.contributions.Contribution
@ -38,6 +42,10 @@ import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.TreeMap
import javax.inject.Inject
import kotlin.collections.ArrayList
@ -80,6 +88,12 @@ class ImageFragment :
*/
var allImages: ArrayList<Image> = ArrayList()
/**
* Keeps track of switch state
*/
private val _switchState = MutableStateFlow(false)
val switchState = _switchState.asStateFlow()
/**
* View model Factory.
*/
@ -214,7 +228,11 @@ class ImageFragment :
switch = binding?.switchWidget
switch?.visibility = View.VISIBLE
switch?.setOnCheckedChangeListener { _, isChecked -> onChangeSwitchState(isChecked) }
_switchState.value = switch?.isChecked ?: false
switch?.setOnCheckedChangeListener { _, isChecked ->
onChangeSwitchState(isChecked)
_switchState.value = isChecked
}
selectorRV = binding?.selectorRv
loader = binding?.loader
progressLayout = binding?.progressLayout
@ -234,6 +252,28 @@ class ImageFragment :
return binding?.root
}
/**
* onViewCreated
* Updates empty text view visibility based on image count, switch state, and loading status.
*/
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
imageAdapter.currentImagesCount,
switchState,
imageAdapter.isLoadingImages
) { imageCount, isChecked, isLoadingImages ->
Triple(imageCount, isChecked, isLoadingImages)
}.collect { (imageCount, isChecked, isLoadingImages) ->
binding?.allImagesUploadedOrMarked?.isVisible =
!isLoadingImages && !isChecked && imageCount == 0 && (switch?.isVisible == true)
}
}
}
}
private fun onChangeSwitchState(checked: Boolean) {
if (checked) {
showAlreadyActionedImages = true

View file

@ -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"
}

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.description
import android.app.ProgressDialog
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.speech.RecognizerIntent
@ -72,7 +71,7 @@ class DescriptionEditActivity :
private lateinit var binding: ActivityDescriptionEditBinding
private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null
private var descriptionAndCaptions: MutableList<UploadMediaDetail>? = null
private val voiceInputResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@ -114,22 +113,18 @@ class DescriptionEditActivity :
* Initializes the RecyclerView
* @param descriptionAndCaptions list of description and caption
*/
private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) {
private fun initRecyclerView(descriptionAndCaptions: MutableList<UploadMediaDetail>?) {
uploadMediaDetailAdapter =
UploadMediaDetailAdapter(
this,
savedLanguageValue,
descriptionAndCaptions,
descriptionAndCaptions ?: mutableListOf(),
recentLanguagesDao,
voiceInputResultLauncher
)
uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int ->
showInfoAlert(
titleStringID,
messageStringId,
)
}
uploadMediaDetailAdapter.setEventListener(this)
uploadMediaDetailAdapter.callback = UploadMediaDetailAdapter.Callback(::showInfoAlert)
uploadMediaDetailAdapter.eventListener = this
rvDescriptions = binding.rvDescriptionsCaptions
rvDescriptions!!.layoutManager = LinearLayoutManager(this)
rvDescriptions!!.adapter = uploadMediaDetailAdapter

View file

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

View file

@ -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();
}

View file

@ -46,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()

View file

@ -41,8 +41,8 @@ import fr.free.nrw.commons.location.LocationPermissionsHelper
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_LOCATION
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_ZOOM
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL
import io.reactivex.android.schedulers.AndroidSchedulers

View file

@ -467,18 +467,35 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
}
}
/**
* Retrieves the ContributionsFragment that is potentially the parent, grandparent, etc
* fragment of this fragment.
*
* @return The ContributionsFragment instance. If the ContributionsFragment instance could not
* be found, null is returned.
*/
private fun getContributionsFragmentParent(): ContributionsFragment? {
var fragment: Fragment? = this
while (fragment != null && fragment !is ContributionsFragment) {
fragment = fragment.parentFragment
}
if (fragment == null) {
return null
}
return fragment as ContributionsFragment
}
override fun onResume() {
super.onResume()
if (parentFragment != null && requireParentFragment().parentFragment != null) {
// Added a check because, not necessarily, the parent fragment
// will have a parent fragment, say in the case when MediaDetailPagerFragment
// is directly started by the CategoryImagesActivity
if (parentFragment is ContributionsFragment) {
(((parentFragment as ContributionsFragment)
.parentFragment) as ContributionsFragment).binding.cardViewNearby.visibility =
View.GONE
}
val contributionsFragment: ContributionsFragment? = this.getContributionsFragmentParent()
if (contributionsFragment?.binding != null) {
contributionsFragment.binding.cardViewNearby.visibility = View.GONE
}
// detail provider is null when fragment is shown in review activity
media = if (detailProvider != null) {
detailProvider!!.getMediaAtPosition(index)
@ -1737,10 +1754,11 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
return
}
ProfileActivity.startYourself(
activity,
media!!.user,
sessionManager.userName != media!!.user
requireActivity(), // Ensure this is a non-null Activity context
media?.user ?: "", // Provide a fallback value if media?.user is null
sessionManager.userName != media?.user // This can remain as is, null check will apply
)
}
/**

View file

@ -291,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();
}

View file

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

View file

@ -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()
}

View file

@ -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;

View file

@ -41,15 +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 &&
BottomSheetBehavior.from(root.parent.parent.parent as RelativeLayout).state !=
BottomSheetBehavior.STATE_HIDDEN
) {
showOrHideAndScrollToIfLast()
onItemClick?.invoke(item)
} else if (hasFocus && !nearbyButtonLayout.buttonLayout.isShown) {
if (bottomSheetBehavior?.state != BottomSheetBehavior.STATE_HIDDEN) {
showOrHideAndScrollToIfLast()
onItemClick?.invoke(item)
}
}
}
nearbyButtonLayout.cameraButton.setOnClickListener { onCameraClicked(item, inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult) }

View file

@ -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<Place> fetchPlaces(double latBegin, double lngBegin,
double latEnd, double lngEnd);
/**
* Saves a Place object asynchronously into the database.
*/

View file

@ -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<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.
*

View file

@ -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<Place> fetchPlaces(final LatLng mapBottomLeft, final LatLng mapTopRight) {
return localDataSource.fetchPlaces(mapBottomLeft, mapTopRight);
}
/**
* Clears the Nearby cache on an IO thread.
*

View file

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

View file

@ -18,6 +18,8 @@ public interface NearbyParentFragmentContract {
boolean isNetworkConnectionEstablished();
void updateSnackbar(boolean offlinePinsShown);
void listOptionMenuItemClicked();
void populatePlaces(LatLng currentLatLng);
@ -91,6 +93,10 @@ public interface NearbyParentFragmentContract {
LatLng getMapFocus();
LatLng getScreenTopRight();
LatLng getScreenBottomLeft();
boolean isAdvancedQueryFragmentVisible();
void showHideAdvancedQueryFragment(boolean shouldShow);
@ -122,8 +128,6 @@ public interface NearbyParentFragmentContract {
void filterByMarkerType(List<Label> selectedLabels, int state, boolean filterForPlaceState,
boolean filterForAllNoneType);
void updateMapMarkersToController(List<BaseMarker> baseMarkers);
void searchViewGainedFocus();
void setCheckboxUnknown();
@ -131,5 +135,7 @@ public interface NearbyParentFragmentContract {
void setAdvancedQuery(String query);
void toggleBookmarkedStatus(Place place);
void handleMapScrolled(LifecycleCoroutineScope scope, boolean isNetworkAvailable);
}
}

View file

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

View file

@ -55,6 +55,7 @@ import androidx.appcompat.app.AlertDialog.Builder;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.lifecycle.LifecycleCoroutineScope;
import androidx.lifecycle.LifecycleOwnerKt;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.GridLayoutManager;
@ -64,7 +65,6 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa
import com.google.android.material.snackbar.Snackbar;
import com.jakewharton.rxbinding2.view.RxView;
import com.jakewharton.rxbinding3.appcompat.RxSearchView;
import fr.free.nrw.commons.BaseMarker;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.CommonsApplication.BaseLogoutListener;
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.Place;
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.contract.NearbyParentFragmentContract;
import fr.free.nrw.commons.nearby.fragments.AdvanceQueryFragment.Callback;
@ -208,6 +209,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
private boolean isNetworkErrorOccurred;
private Snackbar snackbar;
private View view;
private LifecycleCoroutineScope scope;
private NearbyParentFragmentPresenter presenter;
private boolean isDarkTheme;
private boolean isFABsExpanded;
@ -231,10 +233,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
private Place nearestPlace;
private volatile boolean stopQuery;
private boolean isSearchInProgress = false;
private final Handler searchHandler = new Handler();
private Runnable searchRunnable;
private static final long SCROLL_DELAY = 800; // Delay for debounce of onscroll, in milliseconds.
private LatLng updatedLatLng;
private boolean searchable;
@ -341,6 +341,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
view = binding.getRoot();
initNetworkBroadCastReceiver();
scope = LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner());
presenter = new NearbyParentFragmentPresenter(bookmarkLocationDao, placesRepository, nearbyController);
progressDialog = new ProgressDialog(getActivity());
progressDialog.setCancelable(false);
@ -471,28 +472,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
binding.map.addMapListener(new MapListener() {
@Override
public boolean onScroll(ScrollEvent event) {
// 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);
presenter.handleMapScrolled(scope, !isNetworkErrorOccurred);
return true;
}
@ -1060,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
*/
@ -1074,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
public void populatePlaces(final LatLng currentLatLng) {
IGeoPoint screenTopRight = binding.map.getProjection()
.fromPixels(binding.map.getWidth(), 0);
IGeoPoint screenBottomLeft = binding.map.getProjection()
.fromPixels(0, binding.map.getHeight());
LatLng screenTopRightLatLng = new LatLng(
screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0);
LatLng screenBottomLeftLatLng = new LatLng(
screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0);
// these two variables have historically been assigned values the opposite of what their
// names imply, and quite some existing code depends on this fact
LatLng screenTopRightLatLng = getScreenBottomLeft();
LatLng screenBottomLeftLatLng = getScreenTopRight();
// When the nearby fragment is opened immediately upon app launch, the {screenTopRightLatLng}
// and {screenBottomLeftLatLng} variables return {LatLng(0.0,0.0)} as output.
@ -1136,14 +1155,10 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
populatePlaces(currentLatLng);
return;
}
IGeoPoint screenTopRight = binding.map.getProjection()
.fromPixels(binding.map.getWidth(), 0);
IGeoPoint screenBottomLeft = binding.map.getProjection()
.fromPixels(0, binding.map.getHeight());
LatLng screenTopRightLatLng = new LatLng(
screenBottomLeft.getLatitude(), screenBottomLeft.getLongitude(), 0);
LatLng screenBottomLeftLatLng = new LatLng(
screenTopRight.getLatitude(), screenTopRight.getLongitude(), 0);
// these two variables have historically been assigned values the opposite of what their
// names imply, and quite some existing code depends on this fact
final LatLng screenTopRightLatLng = getScreenBottomLeft();
final LatLng screenBottomLeftLatLng = getScreenTopRight();
if (currentLatLng.equals(lastFocusLocation) || lastFocusLocation == null
|| recenterToUserLocation) { // Means we are checking around current location
@ -1159,27 +1174,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
}
/**
* Reloads the Nearby map
* 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
* Clears the Nearby local cache and then calls for pin details to be fetched afresh.
*
*/
private void emptyCache() {
@ -1188,7 +1183,22 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
placesRepository.clearCache()
.subscribeOn(Schedulers.io())
.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(
() -> {
Timber.d("Nearby Cache cleared successfully.");
@ -1477,8 +1487,7 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
*/
private void updateMapMarkers(final List<Place> nearbyPlaces, final LatLng curLatLng,
final boolean shouldUpdateSelectedMarker) {
presenter.updateMapMarkers(nearbyPlaces, curLatLng,
LifecycleOwnerKt.getLifecycleScope(getViewLifecycleOwner()));
presenter.updateMapMarkers(nearbyPlaces, curLatLng, scope);
}
@ -1614,6 +1623,8 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
// prompt the user to login
new Builder(getContext())
.setMessage(R.string.login_alert_message)
.setCancelable(false)
.setNegativeButton(R.string.cancel, (dialog, which) -> {})
.setPositiveButton(R.string.login, (dialog, which) -> {
// logout of the app
BaseLogoutListener logoutListener = new BaseLogoutListener(getActivity());
@ -1859,26 +1870,31 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
place.getLongDescription()) : place.getLongDescription());
}
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) -> {
if (clickedMarker != null) {
clickedMarker.closeInfoWindow();
}
clickedMarker = marker1;
binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.VISIBLE);
binding.bottomSheetDetails.icon.setVisibility(View.GONE);
binding.bottomSheetDetails.wikiDataLl.setVisibility(View.GONE);
if (Objects.equals(place.name, "")) {
getPlaceData(place.getWikiDataEntityId(), place, marker1, isBookMarked);
if (!isNetworkErrorOccurred) {
binding.bottomSheetDetails.dataCircularProgress.setVisibility(View.VISIBLE);
binding.bottomSheetDetails.icon.setVisibility(View.GONE);
binding.bottomSheetDetails.wikiDataLl.setVisibility(View.GONE);
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 {
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 marker;

View file

@ -4,7 +4,6 @@ import android.location.Location
import android.view.View
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleCoroutineScope
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
@ -52,6 +51,10 @@ class NearbyParentFragmentPresenter
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>()
/**
@ -297,10 +300,27 @@ class NearbyParentFragmentPresenter
}
?: return
loadPlacesDataAyncJob?.cancel()
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()
@ -489,17 +509,52 @@ class NearbyParentFragmentPresenter
}
}
@MainThread
override fun updateMapMarkersToController(baseMarkers: MutableList<BaseMarker>) {
NearbyController.markerLabelList.clear()
for (i in baseMarkers.indices) {
val nearbyBaseMarker = baseMarkers[i]
NearbyController.markerLabelList.add(
MarkerPlaceGroup(
bookmarkLocationDao.findBookmarkLocation(nearbyBaseMarker.place),
nearbyBaseMarker.place
)
)
/**
* 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())
}
}
}
}
@ -575,6 +630,8 @@ class NearbyParentFragmentPresenter
}
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),

View file

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

View file

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

View file

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

View file

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

View file

@ -40,10 +40,10 @@ class QuizChecker @Inject constructor(
private val compositeDisposable = CompositeDisposable()
private val UPLOAD_COUNT_THRESHOLD = 5
private val REVERT_PERCENTAGE_FOR_MESSAGE = "50%"
private val REVERT_SHARED_PREFERENCE = "revertCount"
private val UPLOAD_SHARED_PREFERENCE = "uploadCount"
private val uploadCountThreshold = 5
private val revertPercentageForMessage = "50%"
private val revertSharedPreference = "revertCount"
private val uploadSharedPreference = "uploadCount"
/**
* Initializes quiz check by calculating revert parameters and showing quiz if necessary
@ -80,12 +80,12 @@ class QuizChecker @Inject constructor(
*/
private fun setTotalUploadCount(uploadCount: Int) {
totalUploadCount = uploadCount - revertKvStore.getInt(
UPLOAD_SHARED_PREFERENCE,
uploadSharedPreference,
0
)
if (totalUploadCount < 0) {
totalUploadCount = 0
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0)
revertKvStore.putInt(uploadSharedPreference, 0)
}
isUploadCountFetched = true
}
@ -112,10 +112,10 @@ class QuizChecker @Inject constructor(
* @param revertCountFetched Count of deleted uploads
*/
private fun setRevertParameter(revertCountFetched: Int) {
revertCount = revertCountFetched - revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0)
revertCount = revertCountFetched - revertKvStore.getInt(revertSharedPreference, 0)
if (revertCount < 0) {
revertCount = 0
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0)
revertKvStore.putInt(revertSharedPreference, 0)
}
isRevertCountFetched = true
}
@ -128,13 +128,13 @@ class QuizChecker @Inject constructor(
setRevertCount()
if (revertCount < 0 || totalUploadCount < 0) {
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, 0)
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, 0)
revertKvStore.putInt(revertSharedPreference, 0)
revertKvStore.putInt(uploadSharedPreference, 0)
return
}
if (isRevertCountFetched && isUploadCountFetched &&
totalUploadCount >= UPLOAD_COUNT_THRESHOLD &&
totalUploadCount >= uploadCountThreshold &&
(revertCount * 100) / totalUploadCount >= 50
) {
callQuiz(activity)
@ -149,7 +149,7 @@ class QuizChecker @Inject constructor(
DialogUtil.showAlertDialog(
activity,
activity.getString(R.string.quiz),
activity.getString(R.string.quiz_alert_message, REVERT_PERCENTAGE_FOR_MESSAGE),
activity.getString(R.string.quiz_alert_message, revertPercentageForMessage),
activity.getString(R.string.about_translate_proceed),
activity.getString(android.R.string.cancel),
{ startQuizActivity(activity) },
@ -161,11 +161,11 @@ class QuizChecker @Inject constructor(
* Starts the quiz activity and updates preferences for revert and upload counts
*/
private fun startQuizActivity(activity: Activity) {
val newRevertSharedPrefs = revertCount + revertKvStore.getInt(REVERT_SHARED_PREFERENCE, 0)
revertKvStore.putInt(REVERT_SHARED_PREFERENCE, newRevertSharedPrefs)
val newRevertSharedPrefs = revertCount + revertKvStore.getInt(revertSharedPreference, 0)
revertKvStore.putInt(revertSharedPreference, newRevertSharedPrefs)
val newUploadCount = totalUploadCount + revertKvStore.getInt(UPLOAD_SHARED_PREFERENCE, 0)
revertKvStore.putInt(UPLOAD_SHARED_PREFERENCE, newUploadCount)
val newUploadCount = totalUploadCount + revertKvStore.getInt(uploadSharedPreference, 0)
revertKvStore.putInt(uploadSharedPreference, newUploadCount)
val intent = Intent(activity, WelcomeActivity::class.java).apply {
putExtra("isQuiz", true)

View file

@ -14,11 +14,15 @@ class QuizController {
private val quiz: ArrayList<QuizQuestion> = ArrayList()
private val URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg"
private val URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg"
private val URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg"
private val URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png"
private val URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg"
companion object{
const val URL_FOR_SELFIE = "https://i.imgur.com/0fMYcpM.jpg"
const val URL_FOR_TAJ_MAHAL = "https://upload.wikimedia.org/wikipedia/commons/1/15/Taj_Mahal-03.jpg"
const val URL_FOR_BLURRY_IMAGE = "https://i.imgur.com/Kepb5jR.jpg"
const val URL_FOR_SCREENSHOT = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Social_media_app_mockup_screenshot.svg/500px-Social_media_app_mockup_screenshot.svg.png"
const val URL_FOR_EVENT = "https://upload.wikimedia.org/wikipedia/commons/5/51/HouseBuildingInNorthernVietnam.jpg"
}
fun initialize(context: Context) {
val q1 = QuizQuestion(

View file

@ -30,8 +30,8 @@ import fr.free.nrw.commons.contributions.MainActivity
class QuizResultActivity : AppCompatActivity() {
private var binding: ActivityQuizResultBinding? = null
private val NUMBER_OF_QUESTIONS = 5
private val MULTIPLIER_TO_GET_PERCENTAGE = 20
private val numberOfQuestions = 5
private val multiplierToGetPercentage = 20
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -67,9 +67,9 @@ class QuizResultActivity : AppCompatActivity() {
*/
@SuppressLint("StringFormatInvalid", "SetTextI18n")
fun setScore(score: Int) {
val scorePercent = score * MULTIPLIER_TO_GET_PERCENTAGE
val scorePercent = score * multiplierToGetPercentage
binding?.resultProgressBar?.progress = scorePercent
binding?.tvResultProgress?.text = "$score / $NUMBER_OF_QUESTIONS"
binding?.tvResultProgress?.text = "$score / $numberOfQuestions"
val message = resources.getString(R.string.congratulatory_message_quiz, "$scorePercent%")
binding?.congratulatoryMessage?.text = message
}

View file

@ -17,7 +17,7 @@ import java.util.HashMap
class RecentLanguagesAdapter constructor(
context: Context,
var recentLanguages: List<Language>,
private val selectedLanguages: HashMap<*, String>,
private val selectedLanguages: MutableMap<Int, String>,
) : ArrayAdapter<String?>(context, R.layout.row_item_languages_spinner) {
/**
* Selected language code in UploadMediaDetailAdapter

View file

@ -46,7 +46,7 @@ class UploadRepository @Inject constructor(
*
* @return
*/
fun buildContributions(): Observable<Contribution>? {
fun buildContributions(): Observable<Contribution> {
return uploadModel.buildContributions()
}
@ -69,7 +69,7 @@ class UploadRepository @Inject constructor(
* @return
*/
fun getUploads(): List<UploadItem> {
return uploadModel.getUploads()
return uploadModel.uploads
}
/**
@ -177,7 +177,7 @@ class UploadRepository @Inject constructor(
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): Observable<UploadItem>? {
): Observable<UploadItem> {
return uploadModel.preProcessImage(
uploadableFile,
place,
@ -193,7 +193,7 @@ class UploadRepository @Inject constructor(
* @param location Location of the image
* @return Quality of UploadItem
*/
fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int>? {
fun getImageQuality(uploadItem: UploadItem, location: LatLng?): Single<Int> {
return uploadModel.getImageQuality(uploadItem, location)
}
@ -213,7 +213,7 @@ class UploadRepository @Inject constructor(
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
fun getCaptionQuality(uploadItem: UploadItem): Single<Int>? {
fun getCaptionQuality(uploadItem: UploadItem): Single<Int> {
return uploadModel.getCaptionQuality(uploadItem)
}
@ -275,7 +275,7 @@ class UploadRepository @Inject constructor(
* @param selectedExistingDepictions existing depicts
*/
fun setSelectedExistingDepictions(selectedExistingDepictions: List<String>) {
uploadModel.selectedExistingDepictions = selectedExistingDepictions
uploadModel.selectedExistingDepictions = selectedExistingDepictions.toMutableList()
}
/**

View file

@ -45,12 +45,12 @@ class ReviewActivity : BaseActivity() {
private var hasNonHiddenCategories = false
var media: Media? = null
private val SAVED_MEDIA = "saved_media"
private val savedMedia = "saved_media"
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
media?.let {
outState.putParcelable(SAVED_MEDIA, it)
outState.putParcelable(savedMedia, it)
}
}
@ -90,8 +90,8 @@ class ReviewActivity : BaseActivity() {
PorterDuff.Mode.SRC_IN
)
if (savedInstanceState?.getParcelable<Media>(SAVED_MEDIA) != null) {
updateImage(savedInstanceState.getParcelable(SAVED_MEDIA)!!)
if (savedInstanceState?.getParcelable<Media>(savedMedia) != null) {
updateImage(savedInstanceState.getParcelable(savedMedia)!!)
setUpMediaDetailOnOrientation()
} else {
runRandomizer()
@ -188,7 +188,7 @@ class ReviewActivity : BaseActivity() {
return
}
binding.reviewImageView.setImageURI(media.imageUrl)
binding.reviewImageView.setImageURI(media.thumbUrl)
reviewController.onImageRefreshed(media) // filename is updated
compositeDisposable.add(

View file

@ -31,7 +31,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
lateinit var sessionManager: SessionManager
// Constant variable used to store user's key name for onSaveInstanceState method
private val SAVED_USER = "saved_user"
private val savedUser = "saved_user"
// Variable that stores the value of user
private var user: String? = null
@ -129,7 +129,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
question = getString(R.string.review_thanks)
user = reviewActivity.reviewController.firstRevision?.user()
?: savedInstanceState?.getString(SAVED_USER)
?: savedInstanceState?.getString(savedUser)
//if the user is null because of whatsoever reason, review will not be sent anyways
if (!user.isNullOrEmpty()) {
@ -172,7 +172,7 @@ class ReviewImageFragment : CommonsDaggerSupportFragment() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
//Save user name when configuration changes happen
outState.putString(SAVED_USER, user)
outState.putString(savedUser, user)
}
private val reviewCallback: ReviewController.ReviewCallback

View file

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

View file

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

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.settings
import android.Manifest.permission
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.Context.MODE_PRIVATE
@ -11,9 +10,9 @@ import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.View
import android.widget.AdapterView
import android.widget.Button
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView
@ -21,6 +20,7 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AlertDialog
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
@ -34,8 +34,10 @@ import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.PermissionToken
import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
import fr.free.nrw.commons.BuildConfig.MOBILE_META_URL
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.activity.SingleWebViewActivity
import fr.free.nrw.commons.campaigns.CampaignView
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.contributions.MainActivity
@ -50,6 +52,7 @@ import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.upload.LanguagesAdapter
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.PermissionUtils
import fr.free.nrw.commons.utils.StringUtil
import fr.free.nrw.commons.utils.ViewUtil
import java.util.Locale
import javax.inject.Inject
@ -73,6 +76,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
@Inject
lateinit var locationManager: LocationServiceManager
private var vanishAccountPreference: Preference? = null
private var themeListPreference: ListPreference? = null
private var descriptionLanguageListPreference: Preference? = null
private var descriptionSecondaryLanguagesListPreference: Preference? = null
@ -82,8 +86,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private var recentLanguagesTextView: TextView? = null
private var separator: View? = null
private var languageHistoryListView: ListView? = null
private lateinit var inAppCameraLocationPermissionLauncher: ActivityResultLauncher<Array<String>>
private val GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content"
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
@ -117,6 +121,26 @@ class SettingsFragment : PreferenceFragmentCompat() {
themeListPreference = findPreference(Prefs.KEY_THEME_VALUE)
prepareTheme()
vanishAccountPreference = findPreference(Prefs.VANISHED_ACCOUNT)
vanishAccountPreference?.setOnPreferenceClickListener {
AlertDialog.Builder(requireContext())
.setTitle(R.string.account_vanish_request_confirm_title)
.setMessage(StringUtil.fromHtml(getString(R.string.account_vanish_request_confirm)))
.setNegativeButton(R.string.cancel){ dialog,_ ->
dialog.dismiss()
}
.setPositiveButton(R.string.vanish_account) { dialog, _ ->
SingleWebViewActivity.showWebView(
context = requireActivity(),
url = VANISH_ACCOUNT_URL,
successUrl = VANISH_ACCOUNT_SUCCESS_URL
)
dialog.dismiss()
}
.show()
true
}
val multiSelectListPref: MultiSelectListPreference? = findPreference(
Prefs.MANAGED_EXIF_TAGS
)
@ -132,7 +156,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue ->
val isInAppCameraLocationTurnedOn = newValue as Boolean
if (isInAppCameraLocationTurnedOn) {
createDialogsAndHandleLocationPermissions(requireActivity())
createDialogsAndHandleLocationPermissions()
}
true
}
@ -255,6 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
findPreference<Preference>("managed_exif_tags")?.isEnabled = false
findPreference<Preference>("openDocumentPhotoPickerPref")?.isEnabled = false
findPreference<Preference>("inAppCameraLocationPref")?.isEnabled = false
findPreference<Preference>("vanishAccount")?.isEnabled = false
}
}
@ -263,7 +288,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
*
* @param activity
*/
private fun createDialogsAndHandleLocationPermissions(activity: Activity) {
private fun createDialogsAndHandleLocationPermissions() {
inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION))
}
@ -291,7 +316,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
return object : PreferenceGroupAdapter(preferenceScreen) {
override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
val preference = getItem(position)
val iconFrame: View? = holder.itemView.findViewById(R.id.icon_frame)
iconFrame?.visibility = View.GONE
}
@ -426,24 +450,16 @@ class SettingsFragment : PreferenceFragmentCompat() {
val dialog = Dialog(requireActivity())
dialog.setContentView(R.layout.dialog_select_language)
dialog.setCancelable(true)// Allow dialog to close with the back button
dialog.setCancelable(false)
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.90).toInt(),
(resources.displayMetrics.heightPixels * 0.90).toInt()
)
// Handle back button explicitly to dismiss the dialog
dialog.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
dialog.dismiss() // Close the dialog when the back button is pressed
true
} else {
false
}
}
dialog.show()
val editText: EditText = dialog.findViewById(R.id.search_language)
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)
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches)
separator = dialog.findViewById(R.id.separator)
@ -452,6 +468,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
listView.adapter = languagesAdapter
cancelButton.setOnClickListener { dialog.dismiss() }
editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) {
hideRecentLanguagesSection()
@ -585,7 +603,11 @@ class SettingsFragment : PreferenceFragmentCompat() {
editor.apply()
}
@Suppress("LongLine")
companion object {
const val GET_CONTENT_PICKER_HELP_URL = "https://commons-app.github.io/docs.html#get-content"
private const val VANISH_ACCOUNT_URL = "https://meta.m.wikimedia.org/wiki/Special:Contact/accountvanishapps"
private const val VANISH_ACCOUNT_SUCCESS_URL = "https://meta.m.wikimedia.org/wiki/Special:GlobalVanishRequest/vanished"
/**
* Create Locale based on different types of language codes
* @param languageCode

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
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.net.Uri
import android.text.TextUtils
import android.view.LayoutInflater
@ -13,6 +16,7 @@ import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest
import com.google.android.material.snackbar.Snackbar
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import java.io.File
@ -51,6 +55,24 @@ class FailedUploadsAdapter(
position: Int,
) {
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) {
holder.titleTextView.setText(item.media.displayTitle)
}

View file

@ -52,7 +52,7 @@ class FileProcessor
* Processes filePath coordinates, either from EXIF data or user location
*/
fun processFileCoordinates(
similarImageInterface: SimilarImageInterface,
similarImageInterface: SimilarImageInterface?,
filePath: String?,
inAppPictureLocation: LatLng?,
): ImageCoordinates {
@ -146,7 +146,7 @@ class FileProcessor
*/
private fun findOtherImages(
fileBeingProcessed: File,
similarImageInterface: SimilarImageInterface,
similarImageInterface: SimilarImageInterface?,
) {
val oneHundredAndTwentySeconds = 120 * 1000L
// Time when the original image was created
@ -161,7 +161,7 @@ class FileProcessor
.map { Pair(it, readImageCoordinates(it)) }
.firstOrNull { it.second?.decimalCoords != null }
?.let { fileCoordinatesPair ->
similarImageInterface.showSimilarImageFragment(
similarImageInterface?.showSimilarImageFragment(
fileBeingProcessed.path,
fileCoordinatesPair.first.absolutePath,
fileCoordinatesPair.second,

View file

@ -49,9 +49,10 @@ class FileUtilsWrapper @Inject constructor(private val context: Context) {
while ((bis.read(buffer).also { size = it }) > 0) {
buffers.add(
writeToFile(
buffer.copyOf(size),
buffer,
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.
*/
@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)
try {
if (!file.exists()) {
@ -75,7 +76,7 @@ class FileUtilsWrapper @Inject constructor(private val context: Context) {
}
FileOutputStream(file).use { fos ->
fos.write(data)
fos.write(data, 0, size)
}
} catch (throwable: Exception) {
Timber.e(throwable, "Failed to create file")

View file

@ -10,7 +10,6 @@ import fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK
import fr.free.nrw.commons.utils.ImageUtilsWrapper
import io.reactivex.Single
import io.reactivex.functions.Function
import io.reactivex.schedulers.Schedulers
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
@ -26,7 +25,7 @@ class ImageProcessingService @Inject constructor(
private val fileUtilsWrapper: FileUtilsWrapper,
private val imageUtilsWrapper: ImageUtilsWrapper,
private val readFBMD: ReadFBMD,
private val EXIFReader: EXIFReader,
private val exifReader: EXIFReader,
private val mediaClient: MediaClient
) {
/**
@ -94,7 +93,7 @@ class ImageProcessingService @Inject constructor(
* the presence of some basic Exif metadata.
*/
private fun checkEXIF(filepath: String): Single<Int> =
EXIFReader.processMetadata(filepath)
exifReader.processMetadata(filepath)
/**

View file

@ -23,7 +23,7 @@ import java.util.Locale
*/
class LanguagesAdapter constructor(
context: Context,
private val selectedLanguages: HashMap<*, String>,
private val selectedLanguages: MutableMap<Int, String>,
) : ArrayAdapter<String?>(context, R.layout.row_item_languages_spinner) {
companion object {
/**

View file

@ -1,5 +1,8 @@
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.text.TextUtils
import android.view.LayoutInflater
@ -13,6 +16,7 @@ import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest
import com.google.android.material.snackbar.Snackbar
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import java.io.File
@ -99,6 +103,22 @@ class PendingUploadsAdapter(
fun bind(contribution: Contribution) {
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()
var imageRequest: ImageRequest? = null

View file

@ -1,985 +0,0 @@
package fr.free.nrw.commons.upload;
import static fr.free.nrw.commons.contributions.ContributionController.ACTION_INTERNAL_UPLOADS;
import static fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction;
import static fr.free.nrw.commons.utils.PermissionUtils.getPERMISSIONS_STORAGE;
import static fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE;
import static fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationManager;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.CheckBox;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapter;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionController;
import fr.free.nrw.commons.databinding.ActivityUploadBinding;
import fr.free.nrw.commons.filepicker.Constants.RequestCodes;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.mwapi.UserClient;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadBaseFragment.Callback;
import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment;
import fr.free.nrw.commons.upload.depicts.DepictsFragment;
import fr.free.nrw.commons.upload.license.MediaLicenseFragment;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class UploadActivity extends BaseActivity implements
UploadContract.View, UploadBaseFragment.Callback, ThumbnailsAdapter.OnThumbnailDeletedListener {
@Inject
ContributionController contributionController;
@Inject
@Named("default_preferences")
JsonKvStore directKvStore;
@Inject
UploadContract.UserActionListener presenter;
@Inject
SessionManager sessionManager;
@Inject
UserClient userClient;
@Inject
LocationServiceManager locationManager;
private boolean isTitleExpanded = true;
private CompositeDisposable compositeDisposable;
private ProgressDialog progressDialog;
private UploadImageAdapter uploadImagesAdapter;
private List<UploadBaseFragment> fragments;
private UploadCategoriesFragment uploadCategoriesFragment;
private DepictsFragment depictsFragment;
private MediaLicenseFragment mediaLicenseFragment;
private ThumbnailsAdapter thumbnailsAdapter;
BasicKvStore store;
private Place place;
private LatLng prevLocation;
private LatLng currLocation;
private static boolean uploadIsOfAPlace = false;
private boolean isInAppCameraUpload;
private List<UploadableFile> uploadableFiles = Collections.emptyList();
private int currentSelectedPosition = 0;
/*
Checks for if multiple files selected
*/
private boolean isMultipleFilesSelected = false;
public static final String EXTRA_FILES = "commons_image_exta";
public static final String LOCATION_BEFORE_IMAGE_CAPTURE = "user_location_before_image_capture";
public static final String IN_APP_CAMERA_UPLOAD = "in_app_camera_upload";
/**
* Stores all nearby places found and related users response for
* each place while uploading media
*/
public static HashMap<Place,Boolean> nearbyPopupAnswers;
/**
* A private boolean variable to control whether a permissions dialog should be shown
* when necessary. Initially, it is set to `true`, indicating that the permissions dialog
* should be displayed if permissions are missing and it is first time calling
* `checkStoragePermissions` method.
* This variable is used in the `checkStoragePermissions` method to determine whether to
* show a permissions dialog to the user if the required permissions are not granted.
* If `showPermissionsDialog` is set to `true` and the necessary permissions are missing,
* a permissions dialog will be displayed to request the required permissions. If set
* to `false`, the dialog won't be shown.
*
* @see UploadActivity#checkStoragePermissions()
*/
private boolean showPermissionsDialog = true;
/**
* Whether fragments have been saved.
*/
private boolean isFragmentsSaved = false;
public static final String keyForCurrentUploadImagesSize = "CurrentUploadImagesSize";
public static final String storeNameForCurrentUploadImagesSize = "CurrentUploadImageQualities";
private ActivityUploadBinding binding;
@SuppressLint("CheckResult")
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityUploadBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
/*
If Configuration of device is changed then get the new fragments
created by the system and populate the fragments ArrayList
*/
if (savedInstanceState != null) {
isFragmentsSaved = true;
final List<Fragment> fragmentList = getSupportFragmentManager().getFragments();
fragments = new ArrayList<>();
for (final Fragment fragment : fragmentList) {
fragments.add((UploadBaseFragment) fragment);
}
}
compositeDisposable = new CompositeDisposable();
init();
binding.rlContainerTitle.setOnClickListener(v -> onRlContainerTitleClicked());
nearbyPopupAnswers = new HashMap<>();
//getting the current dpi of the device and if it is less than 320dp i.e. overlapping
//threshold, thumbnails automatically minimizes
final DisplayMetrics metrics = getResources().getDisplayMetrics();
final float dpi = (metrics.widthPixels)/(metrics.density);
if (dpi<=321) {
onRlContainerTitleClicked();
}
if (PermissionUtils.hasPermission(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
locationManager.registerLocationManager();
}
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
locationManager.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER);
store = new BasicKvStore(this, storeNameForCurrentUploadImagesSize);
store.clearAll();
checkStoragePermissions();
}
private void init() {
initProgressDialog();
initViewPager();
initThumbnailsRecyclerView();
//And init other things you need to
}
private void initProgressDialog() {
progressDialog = new ProgressDialog(this);
progressDialog.setMessage(getString(R.string.please_wait));
progressDialog.setCancelable(false);
}
private void initThumbnailsRecyclerView() {
binding.rvThumbnails.setLayoutManager(new LinearLayoutManager(this,
LinearLayoutManager.HORIZONTAL, false));
thumbnailsAdapter = new ThumbnailsAdapter(() -> currentSelectedPosition);
thumbnailsAdapter.setOnThumbnailDeletedListener(this);
binding.rvThumbnails.setAdapter(thumbnailsAdapter);
}
private void initViewPager() {
uploadImagesAdapter = new UploadImageAdapter(getSupportFragmentManager());
binding.vpUpload.setAdapter(uploadImagesAdapter);
binding.vpUpload.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(final int position, final float positionOffset,
final int positionOffsetPixels) {
}
@Override
public void onPageSelected(final int position) {
currentSelectedPosition = position;
if (position >= uploadableFiles.size()) {
binding.cvContainerTopCard.setVisibility(View.GONE);
} else {
thumbnailsAdapter.notifyDataSetChanged();
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
}
}
@Override
public void onPageScrollStateChanged(final int state) {
}
});
}
@Override
public boolean isLoggedIn() {
return sessionManager.isUserLoggedIn();
}
@Override
protected void onResume() {
super.onResume();
presenter.onAttachView(this);
if (!isLoggedIn()) {
askUserToLogIn();
}
checkBlockStatus();
}
/**
* Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
* is created to notify the user
*/
protected void checkBlockStatus() {
compositeDisposable.add(userClient.isUserBlockedFromCommons()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.filter(result -> result)
.subscribe(result -> DialogUtil.showAlertDialog(
this,
getString(R.string.block_notification_title),
getString(R.string.block_notification),
getString(R.string.ok),
this::finish)));
}
public void checkStoragePermissions() {
// Check if all required permissions are granted
final boolean hasAllPermissions = PermissionUtils.hasPermission(this, getPERMISSIONS_STORAGE());
final boolean hasPartialAccess = PermissionUtils.hasPartialAccess(this);
if (hasAllPermissions || hasPartialAccess) {
// All required permissions are granted, so enable UI elements and perform actions
receiveSharedItems();
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
} else {
// Permissions are missing
binding.cvContainerTopCard.setVisibility(View.INVISIBLE);
if(showPermissionsDialog){
checkPermissionsAndPerformAction(this,
() -> {
binding.cvContainerTopCard.setVisibility(View.VISIBLE);
this.receiveSharedItems();
},() -> {
this.showPermissionsDialog = true;
this.checkStoragePermissions();
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale_for_image_share,
getPERMISSIONS_STORAGE());
}
}
/* If all permissions are not granted and a dialog is already showing on screen
showPermissionsDialog will set to false making it not show dialog again onResume,
but if user Denies any permission showPermissionsDialog will be to true
and permissions dialog will be shown again.
*/
this.showPermissionsDialog = hasAllPermissions ;
}
@Override
protected void onStop() {
// Resetting setImageCancelled to false
setImageCancelled(false);
super.onStop();
}
@Override
public void returnToMainActivity() {
finish();
}
/**
* go to the uploadProgress activity to check the status of uploading
*/
@Override
public void goToUploadProgressActivity() {
startActivity(new Intent(this, UploadProgressActivity.class));
}
/**
* Show/Hide the progress dialog
*/
@Override
public void showProgress(final boolean shouldShow) {
if (shouldShow) {
if (!progressDialog.isShowing()) {
progressDialog.show();
}
} else {
if (progressDialog != null && !isFinishing()) {
progressDialog.dismiss();
}
}
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
@Override
public void showMessage(final int messageResourceId) {
ViewUtil.showLongToast(this, messageResourceId);
}
@Override
public List<UploadableFile> getUploadableFiles() {
return uploadableFiles;
}
@Override
public void showHideTopCard(final boolean shouldShow) {
binding.llContainerTopCard.setVisibility(shouldShow ? View.VISIBLE : View.GONE);
}
@Override
public void onUploadMediaDeleted(final int index) {
fragments.remove(index);//Remove the corresponding fragment
uploadableFiles.remove(index);//Remove the files from the list
thumbnailsAdapter.notifyItemRemoved(index); //Notify the thumbnails adapter
uploadImagesAdapter.notifyDataSetChanged(); //Notify the ViewPager
}
@Override
public void updateTopCardTitle() {
binding.tvTopCardTitle.setText(getResources()
.getQuantityString(R.plurals.upload_count_title, uploadableFiles.size(), uploadableFiles.size()));
}
@Override
public void makeUploadRequest() {
WorkRequestHelper.Companion.makeOneTimeWorkRequest(getApplicationContext(),
ExistingWorkPolicy.APPEND_OR_REPLACE);
}
@Override
public void askUserToLogIn() {
Timber.d("current session is null, asking user to login");
ViewUtil.showLongToast(this, getString(R.string.user_not_logged_in));
final Intent loginIntent = new Intent(UploadActivity.this, LoginActivity.class);
startActivity(loginIntent);
}
@Override
public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
boolean areAllGranted = false;
if (requestCode == RequestCodes.STORAGE) {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
for (int i = 0; i < grantResults.length; i++) {
final String permission = permissions[i];
areAllGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED;
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
final boolean showRationale = shouldShowRequestPermissionRationale(permission);
if (!showRationale) {
DialogUtil.showAlertDialog(this,
getString(R.string.storage_permissions_denied),
getString(R.string.unable_to_share_upload_item),
getString(android.R.string.ok),
this::finish);
} else {
DialogUtil.showAlertDialog(this,
getString(R.string.storage_permission_title),
getString(
R.string.write_storage_permission_rationale_for_image_share),
getString(android.R.string.ok),
this::checkStoragePermissions);
}
}
}
if (areAllGranted) {
receiveSharedItems();
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
/**
* Sets the flag indicating whether the upload is of a specific place.
*
* @param uploadOfAPlace a boolean value indicating whether the upload is of place.
*/
public static void setUploadIsOfAPlace(final boolean uploadOfAPlace) {
uploadIsOfAPlace = uploadOfAPlace;
}
private void receiveSharedItems() {
final Intent intent = getIntent();
final String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) {
receiveExternalSharedItems();
} else if (ACTION_INTERNAL_UPLOADS.equals(action)) {
receiveInternalSharedItems();
}
if (uploadableFiles == null || uploadableFiles.isEmpty()) {
handleNullMedia();
} else {
//Show thumbnails
if (uploadableFiles.size() > 1){
if(!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")){//If there is only file, no need to show the image thumbnails
showAlertDialogForCategories();
}
if (uploadableFiles.size() > 3 &&
!defaultKvStore.getBoolean("hasAlreadyLaunchedBigMultiupload")){
showAlertForBattery();
}
thumbnailsAdapter.setUploadableFiles(uploadableFiles);
} else {
binding.llContainerTopCard.setVisibility(View.GONE);
}
binding.tvTopCardTitle.setText(getResources()
.getQuantityString(R.plurals.upload_count_title, uploadableFiles.size(), uploadableFiles.size()));
if(fragments == null){
fragments = new ArrayList<>();
}
for (final UploadableFile uploadableFile : uploadableFiles) {
final UploadMediaDetailFragment uploadMediaDetailFragment = new UploadMediaDetailFragment();
if (!uploadIsOfAPlace) {
handleLocation();
uploadMediaDetailFragment.setImageToBeUploaded(uploadableFile, place, currLocation);
locationManager.unregisterLocationManager();
} else {
uploadMediaDetailFragment.setImageToBeUploaded(uploadableFile, place, currLocation);
}
final UploadMediaDetailFragmentCallback uploadMediaDetailFragmentCallback = new UploadMediaDetailFragmentCallback() {
@Override
public void deletePictureAtIndex(final int index) {
store.putInt(keyForCurrentUploadImagesSize,
(store.getInt(keyForCurrentUploadImagesSize) - 1));
presenter.deletePictureAtIndex(index);
}
/**
* Changes the thumbnail of an UploadableFile at the specified index.
* This method updates the list of uploadableFiles by replacing the UploadableFile
* at the given index with a new UploadableFile created from the provided file path.
* After updating the list, it notifies the RecyclerView's adapter to refresh its data,
* ensuring that the thumbnail change is reflected in the UI.
*
* @param index The index of the UploadableFile to be updated.
* @param filepath The file path of the new thumbnail image.
*/
@Override
public void changeThumbnail(final int index, final String filepath) {
uploadableFiles.remove(index);
uploadableFiles.add(index, new UploadableFile(new File(filepath)));
binding.rvThumbnails.getAdapter().notifyDataSetChanged();
}
@Override
public void onNextButtonClicked(final int index) {
UploadActivity.this.onNextButtonClicked(index);
}
@Override
public void onPreviousButtonClicked(final int index) {
UploadActivity.this.onPreviousButtonClicked(index);
}
@Override
public void showProgress(final boolean shouldShow) {
UploadActivity.this.showProgress(shouldShow);
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
};
if(isFragmentsSaved){
final UploadMediaDetailFragment fragment = (UploadMediaDetailFragment) fragments.get(0);
fragment.setCallback(uploadMediaDetailFragmentCallback);
}else{
uploadMediaDetailFragment.setCallback(uploadMediaDetailFragmentCallback);
fragments.add(uploadMediaDetailFragment);
}
}
//If fragments are not created, create them and add them to the fragments ArrayList
if(!isFragmentsSaved){
uploadCategoriesFragment = new UploadCategoriesFragment();
if (place != null) {
final Bundle categoryBundle = new Bundle();
categoryBundle.putString(SELECTED_NEARBY_PLACE_CATEGORY, place.getCategory());
uploadCategoriesFragment.setArguments(categoryBundle);
}
uploadCategoriesFragment.setCallback(this);
depictsFragment = new DepictsFragment();
final Bundle placeBundle = new Bundle();
placeBundle.putParcelable(SELECTED_NEARBY_PLACE, place);
depictsFragment.setArguments(placeBundle);
depictsFragment.setCallback(this);
mediaLicenseFragment = new MediaLicenseFragment();
mediaLicenseFragment.setCallback(this);
fragments.add(depictsFragment);
fragments.add(uploadCategoriesFragment);
fragments.add(mediaLicenseFragment);
}else{
for(int i=1;i<fragments.size();i++){
fragments.get(i).setCallback(new Callback() {
@Override
public void onNextButtonClicked(final int index) {
if (index < fragments.size() - 1) {
binding.vpUpload.setCurrentItem(index + 1, false);
fragments.get(index + 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 0) ? index-1 : 0, 0);
} else {
presenter.handleSubmit();
}
}
@Override
public void onPreviousButtonClicked(final int index) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true);
fragments.get(index - 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 3) ? index-2 : 0, 0);
}
}
@Override
public void showProgress(final boolean shouldShow) {
if (shouldShow) {
if (!progressDialog.isShowing()) {
progressDialog.show();
}
} else {
if (progressDialog != null && !isFinishing()) {
progressDialog.dismiss();
}
}
}
@Override
public int getIndexInViewFlipper(final UploadBaseFragment fragment) {
return fragments.indexOf(fragment);
}
@Override
public int getTotalNumberOfSteps() {
return fragments.size();
}
@Override
public boolean isWLMUpload() {
return place!=null && place.isMonument();
}
});
}
}
uploadImagesAdapter.setFragments(fragments);
binding.vpUpload.setOffscreenPageLimit(fragments.size());
}
// Saving size of uploadableFiles
store.putInt(keyForCurrentUploadImagesSize, uploadableFiles.size());
}
/**
* Users may uncheck Location tag from the Manage EXIF tags setting any time.
* So, their location must not be shared in this case.
*
*/
private boolean isLocationTagUncheckedInTheSettings() {
final Set<String> prefExifTags = defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS);
if (prefExifTags.contains(getString(R.string.exif_tag_location))) {
return false;
}
return true;
}
/**
* Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail.
* Fixes: <a href="https://github.com/commons-app/apps-android-commons/issues/5511">Issue</a>
*
* @param index Index of image to be removed
* @param maxSize Max size of the {@code uploadableFiles}
*/
@Override
public void highlightNextImageOnCancelledImage(final int index, final int maxSize) {
if (binding.vpUpload != null && index < (maxSize)) {
binding.vpUpload.setCurrentItem(index + 1, false);
binding.vpUpload.setCurrentItem(index, false);
}
}
/**
* Used to check if user has cancelled upload of any image in current upload
* so that location compare doesn't show up again in same upload.
* Fixes: <a href="https://github.com/commons-app/apps-android-commons/issues/5511">Issue</a>
*
* @param isCancelled Is true when user has cancelled upload of any image in current upload
*/
@Override
public void setImageCancelled(final boolean isCancelled) {
final BasicKvStore basicKvStore = new BasicKvStore(this,"IsAnyImageCancelled");
basicKvStore.putBoolean("IsAnyImageCancelled", isCancelled);
}
/**
* Calculate the difference between current location and
* location recorded before capturing the image
*
*/
private float getLocationDifference(final LatLng currLocation, final LatLng prevLocation) {
if (prevLocation == null) {
return 0.0f;
}
final float[] distance = new float[2];
Location.distanceBetween(
currLocation.getLatitude(), currLocation.getLongitude(),
prevLocation.getLatitude(), prevLocation.getLongitude(), distance);
return distance[0];
}
private void receiveExternalSharedItems() {
uploadableFiles = contributionController.handleExternalImagesPicked(this, getIntent());
}
private void receiveInternalSharedItems() {
final Intent intent = getIntent();
Timber.d("Received intent %s with action %s", intent.toString(), intent.getAction());
uploadableFiles = intent.getParcelableArrayListExtra(EXTRA_FILES);
isMultipleFilesSelected = uploadableFiles.size() > 1;
Timber.i("Received multiple upload %s", uploadableFiles.size());
place = intent.getParcelableExtra(PLACE_OBJECT);
prevLocation = intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE);
isInAppCameraUpload = intent.getBooleanExtra(IN_APP_CAMERA_UPLOAD, false);
resetDirectPrefs();
}
/**
* Returns if multiple files selected or not.
*/
public boolean getIsMultipleFilesSelected() {
return isMultipleFilesSelected;
}
public void resetDirectPrefs() {
directKvStore.remove(PLACE_OBJECT);
}
/**
* Handle null URI from the received intent.
* Current implementation will simply show a toast and finish the upload activity.
*/
private void handleNullMedia() {
ViewUtil.showLongToast(this, R.string.error_processing_image);
finish();
}
@Override
public void showAlertDialog(final int messageResourceId, @NonNull final Runnable onPositiveClick) {
DialogUtil.showAlertDialog(this,
"",
getString(messageResourceId),
getString(R.string.ok),
onPositiveClick);
}
@Override
public void onNextButtonClicked(final int index) {
if (index < fragments.size() - 1) {
binding.vpUpload.setCurrentItem(index + 1, false);
fragments.get(index + 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 0) ? index - 1 : 0, 0);
if (index < fragments.size() - 4) {
// check image quality if next image exists
presenter.checkImageQuality(index + 1);
}
} else {
presenter.handleSubmit();
}
}
@Override
public void onPreviousButtonClicked(final int index) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true);
fragments.get(index - 1).onBecameVisible();
((LinearLayoutManager) binding.rvThumbnails.getLayoutManager())
.scrollToPositionWithOffset((index > 3) ? index-2 : 0, 0);
if ((index != 1) && ((index - 1) < uploadableFiles.size())) {
// Shows the top card if it was hidden because of the last image being deleted and
// now the user has hit previous button to go back to the media details
showHideTopCard(true);
}
}
}
@Override
public void onThumbnailDeleted(final int position) {
presenter.deletePictureAtIndex(position);
}
/**
* The adapter used to show image upload intermediate fragments
*/
private static class UploadImageAdapter extends FragmentStatePagerAdapter {
List<UploadBaseFragment> fragments;
public UploadImageAdapter(final FragmentManager fragmentManager) {
super(fragmentManager);
this.fragments = new ArrayList<>();
}
public void setFragments(final List<UploadBaseFragment> fragments) {
this.fragments = fragments;
notifyDataSetChanged();
}
@NonNull
@Override
public Fragment getItem(final int position) {
return fragments.get(position);
}
@Override
public int getCount() {
return fragments.size();
}
@Override
public int getItemPosition(@NonNull final Object item) {
return PagerAdapter.POSITION_NONE;
}
}
public void onRlContainerTitleClicked() {
binding.rvThumbnails.setVisibility(isTitleExpanded ? View.GONE : View.VISIBLE);
isTitleExpanded = !isTitleExpanded;
binding.ibToggleTopCard.setRotation(binding.ibToggleTopCard.getRotation() + 180);
}
@Override
protected void onDestroy() {
super.onDestroy();
// Resetting all values in store by clearing them
store.clearAll();
presenter.onDetachView();
compositeDisposable.clear();
fragments = null;
uploadImagesAdapter = null;
if (mediaLicenseFragment != null) {
mediaLicenseFragment.setCallback(null);
}
if (uploadCategoriesFragment != null) {
uploadCategoriesFragment.setCallback(null);
}
}
/**
* Get the value of the showPermissionDialog variable.
*
* @return {@code true} if Permission Dialog should be shown, {@code false} otherwise.
*/
public boolean isShowPermissionsDialog() {
return showPermissionsDialog;
}
/**
* Set the value of the showPermissionDialog variable.
*
* @param showPermissionsDialog {@code true} to indicate to show
* Permissions Dialog if permissions are missing, {@code false} otherwise.
*/
public void setShowPermissionsDialog(final boolean showPermissionsDialog) {
this.showPermissionsDialog = showPermissionsDialog;
}
/**
* Overrides the back button to make sure the user is prepared to lose their progress
*/
@Override
public void onBackPressed() {
DialogUtil.showAlertDialog(this,
getString(R.string.back_button_warning),
getString(R.string.back_button_warning_desc),
getString(R.string.back_button_continue),
getString(R.string.back_button_warning),
null,
this::finish
);
}
/**
* If the user uploads more than 1 file informs that
* depictions/categories apply to all pictures of a multi upload.
* This method takes no arguments and does not return any value.
* It shows the AlertDialog and continues the flow of uploads.
*/
private void showAlertDialogForCategories() {
UploadMediaPresenter.isCategoriesDialogShowing = true;
// Inflate the custom layout
final LayoutInflater inflater = getLayoutInflater();
final View view = inflater.inflate(R.layout.activity_upload_categories_dialog, null);
final CheckBox checkBox = view.findViewById(R.id.categories_checkbox);
// Create the alert dialog
final AlertDialog alertDialog = new AlertDialog.Builder(this)
.setView(view)
.setTitle(getString(R.string.multiple_files_depiction_header))
.setMessage(getString(R.string.multiple_files_depiction))
.setPositiveButton("OK", (dialog, which) -> {
if (checkBox.isChecked()) {
// Save the user's choice to not show the dialog again
defaultKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", true);
}
presenter.checkImageQuality(0);
UploadMediaPresenter.isCategoriesDialogShowing = false;
})
.setNegativeButton("", null)
.create();
alertDialog.show();
}
/** Suggest users to turn battery optimisation off when uploading
* more than a few files. That's because we have noticed that
* many-files uploads have a much higher probability of failing
* than uploads with less files. Show the dialog for Android 6
* and above as the ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
* intent was added in API level 23
*/
private void showAlertForBattery(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// When battery-optimisation dialog is shown don't show the image quality dialog
UploadMediaPresenter.isBatteryDialogShowing = true;
DialogUtil.showAlertDialog(
this,
getString(R.string.unrestricted_battery_mode),
getString(R.string.suggest_unrestricted_mode),
getString(R.string.title_activity_settings),
getString(R.string.cancel),
() -> {
/* Since opening the right settings page might be device dependent, using
https://github.com/WaseemSabir/BatteryPermissionHelper
directly appeared like a promising idea.
However, this simply closed the popup and did not make
the settings page appear on a Pixel as well as a Xiaomi device.
Used the standard intent instead of using this library as
it shows a list of all the apps on the device and allows users to
turn battery optimisation off.
*/
final Intent batteryOptimisationSettingsIntent = new Intent(
Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS);
startActivity(batteryOptimisationSettingsIntent);
// calling checkImageQuality after battery dialog is interacted with
// so that 2 dialogs do not pop up simultaneously
UploadMediaPresenter.isBatteryDialogShowing = false;
},
() -> {
UploadMediaPresenter.isBatteryDialogShowing = false;
}
);
defaultKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", true);
}
}
/**
* If the permission for Location is turned on and certain
* conditions are met, returns current location of the user.
*/
private void handleLocation(){
final LocationPermissionsHelper locationPermissionsHelper = new LocationPermissionsHelper(
this, locationManager, null);
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
currLocation = locationManager.getLastLocation();
}
if (currLocation != null) {
final float locationDifference = getLocationDifference(currLocation, prevLocation);
final boolean isLocationTagUnchecked = isLocationTagUncheckedInTheSettings();
/* Remove location if the user has unchecked the Location EXIF tag in the
Manage EXIF Tags setting or turned "Record location for in-app shots" off.
Also, location information is discarded if the difference between
current location and location recorded just before capturing the image
is greater than 100 meters */
if (isLocationTagUnchecked || locationDifference > 100
|| !defaultKvStore.getBoolean("inAppCameraLocationPref")
|| !isInAppCameraUpload) {
currLocation = null;
}
}
}
}

View file

@ -0,0 +1,995 @@
package fr.free.nrw.commons.upload
import android.Manifest
import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.widget.CheckBox
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager.widget.ViewPager.OnPageChangeListener
import androidx.work.ExistingWorkPolicy
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.databinding.ActivityUploadBinding
import fr.free.nrw.commons.filepicker.Constants.RequestCodes
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.location.LocationPermissionsHelper
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.mwapi.UserClient
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.ThumbnailsAdapter.OnThumbnailDeletedListener
import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment
import fr.free.nrw.commons.upload.depicts.DepictsFragment
import fr.free.nrw.commons.upload.license.MediaLicenseFragment
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter
import fr.free.nrw.commons.upload.worker.WorkRequestHelper.Companion.makeOneTimeWorkRequest
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.PermissionUtils.PERMISSIONS_STORAGE
import fr.free.nrw.commons.utils.PermissionUtils.checkPermissionsAndPerformAction
import fr.free.nrw.commons.utils.PermissionUtils.hasPartialAccess
import fr.free.nrw.commons.utils.PermissionUtils.hasPermission
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import fr.free.nrw.commons.wikidata.WikidataConstants.PLACE_OBJECT
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE
import fr.free.nrw.commons.wikidata.WikidataConstants.SELECTED_NEARBY_PLACE_CATEGORY
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Named
class UploadActivity : BaseActivity(), UploadContract.View, UploadBaseFragment.Callback,
OnThumbnailDeletedListener {
@JvmField
@Inject
var contributionController: ContributionController? = null
@JvmField
@Inject
@field:Named("default_preferences")
var directKvStore: JsonKvStore? = null
@JvmField
@Inject
var presenter: UploadContract.UserActionListener? = null
@JvmField
@Inject
var sessionManager: SessionManager? = null
@JvmField
@Inject
var userClient: UserClient? = null
@JvmField
@Inject
var locationManager: LocationServiceManager? = null
private var isTitleExpanded = true
private var progressDialog: ProgressDialog? = null
private var uploadImagesAdapter: UploadImageAdapter? = null
private var fragments: MutableList<UploadBaseFragment>? = null
private var uploadCategoriesFragment: UploadCategoriesFragment? = null
private var depictsFragment: DepictsFragment? = null
private var mediaLicenseFragment: MediaLicenseFragment? = null
private var thumbnailsAdapter: ThumbnailsAdapter? = null
var store: BasicKvStore? = null
private var place: Place? = null
private var prevLocation: LatLng? = null
private var currLocation: LatLng? = null
private var isInAppCameraUpload = false
private var uploadableFiles: MutableList<UploadableFile> = mutableListOf()
private var currentSelectedPosition = 0
/**
* Returns if multiple files selected or not.
*/
/*
Checks for if multiple files selected
*/
var isMultipleFilesSelected: Boolean = false
private set
/**
* Get the value of the showPermissionDialog variable.
*
* @return `true` if Permission Dialog should be shown, `false` otherwise.
*/
/**
* Set the value of the showPermissionDialog variable.
*
* @param showPermissionsDialog `true` to indicate to show
* Permissions Dialog if permissions are missing, `false` otherwise.
*/
/**
* A private boolean variable to control whether a permissions dialog should be shown
* when necessary. Initially, it is set to `true`, indicating that the permissions dialog
* should be displayed if permissions are missing and it is first time calling
* `checkStoragePermissions` method.
* This variable is used in the `checkStoragePermissions` method to determine whether to
* show a permissions dialog to the user if the required permissions are not granted.
* If `showPermissionsDialog` is set to `true` and the necessary permissions are missing,
* a permissions dialog will be displayed to request the required permissions. If set
* to `false`, the dialog won't be shown.
*
* @see UploadActivity.checkStoragePermissions
*/
var isShowPermissionsDialog: Boolean = true
/**
* Whether fragments have been saved.
*/
private var isFragmentsSaved = false
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
/**
* Users may uncheck Location tag from the Manage EXIF tags setting any time.
* So, their location must not be shared in this case.
*
*/
private val isLocationTagUncheckedInTheSettings: Boolean
get() {
val prefExifTags: Set<String> =
defaultKvStore.getStringSet(Prefs.MANAGED_EXIF_TAGS)
return !prefExifTags.contains(getString(R.string.exif_tag_location))
}
private var _binding: ActivityUploadBinding? = null
private val binding: ActivityUploadBinding get() = _binding!!
@SuppressLint("CheckResult")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = ActivityUploadBinding.inflate(layoutInflater)
setContentView(binding.root)
/*
If Configuration of device is changed then get the new fragments
created by the system and populate the fragments ArrayList
*/
if (savedInstanceState != null) {
isFragmentsSaved = true
fragments = mutableListOf<UploadBaseFragment>().apply {
supportFragmentManager.fragments.forEach { fragment ->
add(fragment as UploadBaseFragment)
}
}
}
init()
binding.rlContainerTitle.setOnClickListener { v: View? -> onRlContainerTitleClicked() }
nearbyPopupAnswers = mutableMapOf()
//getting the current dpi of the device and if it is less than 320dp i.e. overlapping
//threshold, thumbnails automatically minimizes
val metrics = resources.displayMetrics
val dpi = (metrics.widthPixels) / (metrics.density)
if (dpi <= 321) {
onRlContainerTitleClicked()
}
if (hasPermission(this, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
locationManager!!.registerLocationManager()
}
locationManager!!.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER)
locationManager!!.requestLocationUpdatesFromProvider(LocationManager.NETWORK_PROVIDER)
store = BasicKvStore(this, storeNameForCurrentUploadImagesSize).apply {
clearAll()
}
checkStoragePermissions()
}
private fun init() {
initProgressDialog()
initViewPager()
initThumbnailsRecyclerView()
//And init other things you need to
}
private fun initProgressDialog() {
progressDialog = ProgressDialog(this)
progressDialog!!.setMessage(getString(R.string.please_wait))
progressDialog!!.setCancelable(false)
}
private fun initThumbnailsRecyclerView() {
binding.rvThumbnails.layoutManager = LinearLayoutManager(
this,
LinearLayoutManager.HORIZONTAL, false
)
thumbnailsAdapter = ThumbnailsAdapter { currentSelectedPosition }
thumbnailsAdapter!!.onThumbnailDeletedListener = this
binding.rvThumbnails.adapter = thumbnailsAdapter
}
private fun initViewPager() {
uploadImagesAdapter = UploadImageAdapter(supportFragmentManager)
binding.vpUpload.adapter = uploadImagesAdapter
binding.vpUpload.addOnPageChangeListener(object : OnPageChangeListener {
override fun onPageScrolled(
position: Int, positionOffset: Float,
positionOffsetPixels: Int
) = Unit
override fun onPageSelected(position: Int) {
currentSelectedPosition = position
if (position >= uploadableFiles!!.size) {
binding.cvContainerTopCard.visibility = View.GONE
} else {
thumbnailsAdapter!!.notifyDataSetChanged()
binding.cvContainerTopCard.visibility = View.VISIBLE
}
}
override fun onPageScrollStateChanged(state: Int) = Unit
})
}
override fun isLoggedIn(): Boolean = sessionManager!!.isUserLoggedIn
override fun onResume() {
super.onResume()
presenter!!.onAttachView(this)
if (!isLoggedIn()) {
askUserToLogIn()
}
checkBlockStatus()
}
/**
* Makes API call to check if user is blocked from Commons. If the user is blocked, a snackbar
* is created to notify the user
*/
protected fun checkBlockStatus() {
compositeDisposable.add(
userClient!!.isUserBlockedFromCommons()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.filter { result: Boolean? -> result!! }
.subscribe { result: Boolean? ->
showAlertDialog(
this,
getString(R.string.block_notification_title),
getString(R.string.block_notification),
getString(R.string.ok)
) { finish() }
})
}
fun checkStoragePermissions() {
// Check if all required permissions are granted
val hasAllPermissions = hasPermission(this, PERMISSIONS_STORAGE)
val hasPartialAccess = hasPartialAccess(this)
if (hasAllPermissions || hasPartialAccess) {
// All required permissions are granted, so enable UI elements and perform actions
receiveSharedItems()
binding.cvContainerTopCard.visibility = View.VISIBLE
} else {
// Permissions are missing
binding.cvContainerTopCard.visibility = View.INVISIBLE
if (isShowPermissionsDialog) {
checkPermissionsAndPerformAction(
this,
Runnable {
binding.cvContainerTopCard.visibility = View.VISIBLE
receiveSharedItems()
}, Runnable {
isShowPermissionsDialog = true
checkStoragePermissions()
},
R.string.storage_permission_title,
R.string.write_storage_permission_rationale_for_image_share,
*PERMISSIONS_STORAGE
)
}
}
/* If all permissions are not granted and a dialog is already showing on screen
showPermissionsDialog will set to false making it not show dialog again onResume,
but if user Denies any permission showPermissionsDialog will be to true
and permissions dialog will be shown again.
*/
isShowPermissionsDialog = hasAllPermissions
}
override fun onStop() {
// Resetting setImageCancelled to false
setImageCancelled(false)
super.onStop()
}
override fun returnToMainActivity() = finish()
/**
* go to the uploadProgress activity to check the status of uploading
*/
override fun goToUploadProgressActivity() =
startActivity(Intent(this, UploadProgressActivity::class.java))
/**
* Show/Hide the progress dialog
*/
override fun showProgress(shouldShow: Boolean) {
if (shouldShow) {
if (!progressDialog!!.isShowing) {
progressDialog!!.show()
}
} else {
if (progressDialog != null && !isFinishing) {
progressDialog!!.dismiss()
}
}
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int =
fragments!!.indexOf(fragment)
override fun showMessage(messageResourceId: Int) {
showLongToast(this, messageResourceId)
}
override fun getUploadableFiles(): List<UploadableFile>? {
return uploadableFiles
}
override fun showHideTopCard(shouldShow: Boolean) {
binding.llContainerTopCard.visibility =
if (shouldShow) View.VISIBLE else View.GONE
}
override fun onUploadMediaDeleted(index: Int) {
fragments!!.removeAt(index) //Remove the corresponding fragment
uploadableFiles.removeAt(index) //Remove the files from the list
thumbnailsAdapter!!.notifyItemRemoved(index) //Notify the thumbnails adapter
uploadImagesAdapter!!.notifyDataSetChanged() //Notify the ViewPager
}
override fun updateTopCardTitle() {
binding.tvTopCardTitle.text = resources
.getQuantityString(
R.plurals.upload_count_title,
uploadableFiles!!.size,
uploadableFiles!!.size
)
}
override fun makeUploadRequest() {
makeOneTimeWorkRequest(
applicationContext,
ExistingWorkPolicy.APPEND_OR_REPLACE
)
}
override fun askUserToLogIn() {
Timber.d("current session is null, asking user to login")
showLongToast(this, getString(R.string.user_not_logged_in))
val loginIntent = Intent(this@UploadActivity, LoginActivity::class.java)
startActivity(loginIntent)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
var areAllGranted = false
if (requestCode == RequestCodes.STORAGE) {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
for (i in grantResults.indices) {
val permission = permissions[i]
areAllGranted = grantResults[i] == PackageManager.PERMISSION_GRANTED
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
val showRationale = shouldShowRequestPermissionRationale(permission)
if (!showRationale) {
showAlertDialog(
this,
getString(R.string.storage_permissions_denied),
getString(R.string.unable_to_share_upload_item),
getString(android.R.string.ok)
) { finish() }
} else {
showAlertDialog(
this,
getString(R.string.storage_permission_title),
getString(
R.string.write_storage_permission_rationale_for_image_share
),
getString(android.R.string.ok)
) { checkStoragePermissions() }
}
}
}
if (areAllGranted) {
receiveSharedItems()
}
}
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
private fun receiveSharedItems() {
val intent = intent
val action = intent.action
if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) {
receiveExternalSharedItems()
} else if (ContributionController.ACTION_INTERNAL_UPLOADS == action) {
receiveInternalSharedItems()
}
if (uploadableFiles == null || uploadableFiles!!.isEmpty()) {
handleNullMedia()
} else {
//Show thumbnails
if (uploadableFiles!!.size > 1) {
if (!defaultKvStore.getBoolean("hasAlreadyLaunchedCategoriesDialog")) { //If there is only file, no need to show the image thumbnails
showAlertDialogForCategories()
}
if (uploadableFiles!!.size > 3 &&
!defaultKvStore.getBoolean("hasAlreadyLaunchedBigMultiupload")
) {
showAlertForBattery()
}
thumbnailsAdapter!!.uploadableFiles = uploadableFiles
} else {
binding.llContainerTopCard.visibility = View.GONE
}
binding.tvTopCardTitle.text = resources
.getQuantityString(
R.plurals.upload_count_title,
uploadableFiles!!.size,
uploadableFiles!!.size
)
if (fragments == null) {
fragments = mutableListOf()
}
for (uploadableFile in uploadableFiles!!) {
val uploadMediaDetailFragment = UploadMediaDetailFragment()
if (!uploadIsOfAPlace) {
handleLocation()
uploadMediaDetailFragment.setImageToBeUploaded(
uploadableFile,
place,
currLocation
)
locationManager!!.unregisterLocationManager()
} else {
uploadMediaDetailFragment.setImageToBeUploaded(
uploadableFile,
place,
currLocation
)
}
val uploadMediaDetailFragmentCallback: UploadMediaDetailFragmentCallback =
object : UploadMediaDetailFragmentCallback {
override fun deletePictureAtIndex(index: Int) {
store!!.putInt(
keyForCurrentUploadImagesSize,
(store!!.getInt(keyForCurrentUploadImagesSize) - 1)
)
presenter!!.deletePictureAtIndex(index)
}
/**
* Changes the thumbnail of an UploadableFile at the specified index.
* This method updates the list of uploadableFiles by replacing the UploadableFile
* at the given index with a new UploadableFile created from the provided file path.
* After updating the list, it notifies the RecyclerView's adapter to refresh its data,
* ensuring that the thumbnail change is reflected in the UI.
*
* @param index The index of the UploadableFile to be updated.
* @param uri The file path of the new thumbnail image.
*/
override fun changeThumbnail(index: Int, uri: String) {
uploadableFiles.removeAt(index)
uploadableFiles.add(index, UploadableFile(File(uri)))
binding.rvThumbnails.adapter!!.notifyDataSetChanged()
}
override fun onNextButtonClicked(index: Int) {
this@UploadActivity.onNextButtonClicked(index)
}
override fun onPreviousButtonClicked(index: Int) {
this@UploadActivity.onPreviousButtonClicked(index)
}
override fun showProgress(shouldShow: Boolean) {
this@UploadActivity.showProgress(shouldShow)
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int {
return fragments!!.indexOf(fragment)
}
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
}
if (isFragmentsSaved) {
val fragment = fragments!![0] as UploadMediaDetailFragment?
fragment!!.fragmentCallback = uploadMediaDetailFragmentCallback
} else {
uploadMediaDetailFragment.fragmentCallback = uploadMediaDetailFragmentCallback
fragments!!.add(uploadMediaDetailFragment)
}
}
//If fragments are not created, create them and add them to the fragments ArrayList
if (!isFragmentsSaved) {
uploadCategoriesFragment = UploadCategoriesFragment()
if (place != null) {
val categoryBundle = Bundle()
categoryBundle.putString(SELECTED_NEARBY_PLACE_CATEGORY, place!!.category)
uploadCategoriesFragment!!.arguments = categoryBundle
}
uploadCategoriesFragment!!.callback = this
depictsFragment = DepictsFragment()
val placeBundle = Bundle()
placeBundle.putParcelable(SELECTED_NEARBY_PLACE, place)
depictsFragment!!.arguments = placeBundle
depictsFragment!!.callback = this
mediaLicenseFragment = MediaLicenseFragment()
mediaLicenseFragment!!.callback = this
fragments!!.add(depictsFragment!!)
fragments!!.add(uploadCategoriesFragment!!)
fragments!!.add(mediaLicenseFragment!!)
} else {
for (i in 1 until fragments!!.size) {
fragments!![i]!!.callback = object : UploadBaseFragment.Callback {
override fun onNextButtonClicked(index: Int) {
if (index < fragments!!.size - 1) {
binding.vpUpload.setCurrentItem(index + 1, false)
fragments!![index + 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(
if ((index > 0)) index - 1 else 0,
0
)
} else {
presenter!!.handleSubmit()
}
}
override fun onPreviousButtonClicked(index: Int) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true)
fragments!![index - 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(
if ((index > 3)) index - 2 else 0,
0
)
}
}
override fun showProgress(shouldShow: Boolean) {
if (shouldShow) {
if (!progressDialog!!.isShowing) {
progressDialog!!.show()
}
} else {
if (progressDialog != null && !isFinishing) {
progressDialog!!.dismiss()
}
}
}
override fun getIndexInViewFlipper(fragment: UploadBaseFragment?): Int {
return fragments!!.indexOf(fragment)
}
override val totalNumberOfSteps: Int
get() = fragments!!.size
override val isWLMUpload: Boolean
get() = place != null && place!!.isMonument
}
}
}
uploadImagesAdapter!!.fragments = fragments!!
binding.vpUpload.offscreenPageLimit = fragments!!.size
}
// Saving size of uploadableFiles
store!!.putInt(keyForCurrentUploadImagesSize, uploadableFiles!!.size)
}
/**
* Changes current image when one image upload is cancelled, to highlight next image in the top thumbnail.
* Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511)
*
* @param index Index of image to be removed
* @param maxSize Max size of the `uploadableFiles`
*/
override fun highlightNextImageOnCancelledImage(index: Int, maxSize: Int) {
if (index < maxSize) {
binding.vpUpload.setCurrentItem(index + 1, false)
binding.vpUpload.setCurrentItem(index, false)
}
}
/**
* Used to check if user has cancelled upload of any image in current upload
* so that location compare doesn't show up again in same upload.
* Fixes: [Issue](https://github.com/commons-app/apps-android-commons/issues/5511)
*
* @param isCancelled Is true when user has cancelled upload of any image in current upload
*/
override fun setImageCancelled(isCancelled: Boolean) {
val basicKvStore = BasicKvStore(this, "IsAnyImageCancelled")
basicKvStore.putBoolean("IsAnyImageCancelled", isCancelled)
}
/**
* Calculate the difference between current location and
* location recorded before capturing the image
*
*/
private fun getLocationDifference(currLocation: LatLng, prevLocation: LatLng?): Float {
if (prevLocation == null) {
return 0.0f
}
val distance = FloatArray(2)
Location.distanceBetween(
currLocation.latitude, currLocation.longitude,
prevLocation.latitude, prevLocation.longitude, distance
)
return distance[0]
}
private fun receiveExternalSharedItems() {
uploadableFiles = contributionController!!.handleExternalImagesPicked(this, intent)
}
private fun receiveInternalSharedItems() {
val intent = intent
Timber.d("Intent has EXTRA_FILES: ${EXTRA_FILES}")
uploadableFiles = try {
// Check if intent has the extra before trying to read it
if (!intent.hasExtra(EXTRA_FILES)) {
Timber.w("No EXTRA_FILES found in intent")
mutableListOf()
} else {
// Try to get the files as Parcelable array
val files = if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(EXTRA_FILES, UploadableFile::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<UploadableFile>(EXTRA_FILES)
}
// Convert to mutable list or return empty list if null
files?.toMutableList() ?: run {
Timber.w("Files array was null")
mutableListOf()
}
}
} catch (e: Exception) {
Timber.e(e, "Error reading files from intent")
mutableListOf()
}
// Log the result for debugging
isMultipleFilesSelected = uploadableFiles.size > 1
Timber.i("Received files count: ${uploadableFiles.size}")
uploadableFiles.forEachIndexed { index, file ->
Timber.d("File $index path: ${file.getFilePath()}")
}
// Handle other extras with null safety
place = try {
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(PLACE_OBJECT, Place::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(PLACE_OBJECT)
}
} catch (e: Exception) {
Timber.e(e, "Error reading place")
null
}
prevLocation = try {
if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE, LatLng::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE)
}
} catch (e: Exception) {
Timber.e(e, "Error reading location")
null
}
isInAppCameraUpload = intent.getBooleanExtra(IN_APP_CAMERA_UPLOAD, false)
resetDirectPrefs()
}
fun resetDirectPrefs() = directKvStore!!.remove(PLACE_OBJECT)
/**
* Handle null URI from the received intent.
* Current implementation will simply show a toast and finish the upload activity.
*/
private fun handleNullMedia() {
showLongToast(this, R.string.error_processing_image)
finish()
}
override fun showAlertDialog(messageResourceId: Int, onPositiveClick: Runnable) {
showAlertDialog(
this,
"",
getString(messageResourceId),
getString(R.string.ok),
onPositiveClick
)
}
override fun onNextButtonClicked(index: Int) {
if (index < fragments!!.size - 1) {
binding.vpUpload.setCurrentItem(index + 1, false)
fragments!![index + 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(if ((index > 0)) index - 1 else 0, 0)
if (index < fragments!!.size - 4) {
// check image quality if next image exists
presenter!!.checkImageQuality(index + 1)
}
} else {
presenter!!.handleSubmit()
}
}
override fun onPreviousButtonClicked(index: Int) {
if (index != 0) {
binding.vpUpload.setCurrentItem(index - 1, true)
fragments!![index - 1]!!.onBecameVisible()
(binding.rvThumbnails.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(if ((index > 3)) index - 2 else 0, 0)
if ((index != 1) && ((index - 1) < uploadableFiles!!.size)) {
// Shows the top card if it was hidden because of the last image being deleted and
// now the user has hit previous button to go back to the media details
showHideTopCard(true)
}
}
}
override fun onThumbnailDeleted(position: Int) = presenter!!.deletePictureAtIndex(position)
/**
* The adapter used to show image upload intermediate fragments
*/
private class UploadImageAdapter(fragmentManager: FragmentManager) :
FragmentStatePagerAdapter(fragmentManager) {
var fragments: List<UploadBaseFragment> = mutableListOf()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItem(position: Int): Fragment {
return fragments[position]
}
override fun getCount(): Int {
return fragments.size
}
override fun getItemPosition(item: Any): Int {
return POSITION_NONE
}
}
fun onRlContainerTitleClicked() {
binding.rvThumbnails.visibility =
if (isTitleExpanded) View.GONE else View.VISIBLE
isTitleExpanded = !isTitleExpanded
binding.ibToggleTopCard.rotation = binding.ibToggleTopCard.rotation + 180
}
override fun onDestroy() {
super.onDestroy()
// Resetting all values in store by clearing them
store!!.clearAll()
presenter!!.onDetachView()
compositeDisposable.clear()
fragments = null
uploadImagesAdapter = null
if (mediaLicenseFragment != null) {
mediaLicenseFragment!!.callback = null
}
if (uploadCategoriesFragment != null) {
uploadCategoriesFragment!!.callback = null
}
}
/**
* Overrides the back button to make sure the user is prepared to lose their progress
*/
@SuppressLint("MissingSuperCall")
override fun onBackPressed() {
showAlertDialog(
this,
getString(R.string.back_button_warning),
getString(R.string.back_button_warning_desc),
getString(R.string.back_button_continue),
getString(R.string.back_button_warning),
null
) { finish() }
}
/**
* If the user uploads more than 1 file informs that
* depictions/categories apply to all pictures of a multi upload.
* This method takes no arguments and does not return any value.
* It shows the AlertDialog and continues the flow of uploads.
*/
private fun showAlertDialogForCategories() {
UploadMediaPresenter.isCategoriesDialogShowing = true
// Inflate the custom layout
val inflater = layoutInflater
val view = inflater.inflate(R.layout.activity_upload_categories_dialog, null)
val checkBox = view.findViewById<CheckBox>(R.id.categories_checkbox)
// Create the alert dialog
val alertDialog = AlertDialog.Builder(this)
.setView(view)
.setTitle(getString(R.string.multiple_files_depiction_header))
.setMessage(getString(R.string.multiple_files_depiction))
.setPositiveButton("OK") { dialog: DialogInterface?, which: Int ->
if (checkBox.isChecked) {
// Save the user's choice to not show the dialog again
defaultKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", true)
}
presenter!!.setupBasicKvStoreFactory { BasicKvStore(this@UploadActivity, it) }
presenter!!.checkImageQuality(0)
UploadMediaPresenter.isCategoriesDialogShowing = false
}
.setNegativeButton("", null)
.create()
alertDialog.show()
}
/** Suggest users to turn battery optimisation off when uploading
* more than a few files. That's because we have noticed that
* many-files uploads have a much higher probability of failing
* than uploads with less files. Show the dialog for Android 6
* and above as the ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
* intent was added in API level 23
*/
private fun showAlertForBattery() {
if (VERSION.SDK_INT >= VERSION_CODES.M) {
// When battery-optimisation dialog is shown don't show the image quality dialog
UploadMediaPresenter.isBatteryDialogShowing = true
showAlertDialog(
this,
getString(R.string.unrestricted_battery_mode),
getString(R.string.suggest_unrestricted_mode),
getString(R.string.title_activity_settings),
getString(R.string.cancel),
{
/* Since opening the right settings page might be device dependent, using
https://github.com/WaseemSabir/BatteryPermissionHelper
directly appeared like a promising idea.
However, this simply closed the popup and did not make
the settings page appear on a Pixel as well as a Xiaomi device.
Used the standard intent instead of using this library as
it shows a list of all the apps on the device and allows users to
turn battery optimisation off.
*/
val batteryOptimisationSettingsIntent = Intent(
Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
)
startActivity(batteryOptimisationSettingsIntent)
// calling checkImageQuality after battery dialog is interacted with
// so that 2 dialogs do not pop up simultaneously
UploadMediaPresenter.isBatteryDialogShowing = false
},
{
UploadMediaPresenter.isBatteryDialogShowing = false
}
)
defaultKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", true)
}
}
/**
* If the permission for Location is turned on and certain
* conditions are met, returns current location of the user.
*/
private fun handleLocation() {
val locationPermissionsHelper = LocationPermissionsHelper(
this, locationManager!!, null
)
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
currLocation = locationManager!!.getLastLocation()
}
if (currLocation != null) {
val locationDifference = getLocationDifference(currLocation!!, prevLocation)
val isLocationTagUnchecked = isLocationTagUncheckedInTheSettings
/* Remove location if the user has unchecked the Location EXIF tag in the
Manage EXIF Tags setting or turned "Record location for in-app shots" off.
Also, location information is discarded if the difference between
current location and location recorded just before capturing the image
is greater than 100 meters */
if (isLocationTagUnchecked || locationDifference > 100 || !defaultKvStore.getBoolean("inAppCameraLocationPref")
|| !isInAppCameraUpload
) {
currLocation = null
}
}
}
companion object {
private var uploadIsOfAPlace = false
const val EXTRA_FILES: String = "commons_image_extra"
const val LOCATION_BEFORE_IMAGE_CAPTURE: String = "user_location_before_image_capture"
const val IN_APP_CAMERA_UPLOAD: String = "in_app_camera_upload"
/**
* Stores all nearby places found and related users response for
* each place while uploading media
*/
@JvmField
var nearbyPopupAnswers: MutableMap<Place, Boolean>? = null
const val keyForCurrentUploadImagesSize: String = "CurrentUploadImagesSize"
const val storeNameForCurrentUploadImagesSize: String = "CurrentUploadImageQualities"
/**
* Sets the flag indicating whether the upload is of a specific place.
*
* @param uploadOfAPlace a boolean value indicating whether the upload is of place.
*/
@JvmStatic
fun setUploadIsOfAPlace(uploadOfAPlace: Boolean) {
uploadIsOfAPlace = uploadOfAPlace
}
}
}

View file

@ -8,7 +8,7 @@ import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
abstract class UploadBaseFragment : CommonsDaggerSupportFragment() {
var callback: Callback? = null
protected open fun onBecameVisible() = Unit
open fun onBecameVisible() = Unit
interface Callback {
val totalNumberOfSteps: Int

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.upload
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
/**
* The contract using which the UplaodActivity would communicate with its presenter
@ -73,5 +74,7 @@ interface UploadContract {
* @param uploadItemIndex Index of next image, whose quality is to be checked
*/
fun checkImageQuality(uploadItemIndex: Int)
fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore)
}
}

View file

@ -5,7 +5,6 @@ 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?,

View file

@ -8,7 +8,7 @@ import kotlinx.parcelize.Parcelize
* Holds a description of an item being uploaded by [UploadActivity]
*/
@Parcelize
data class UploadMediaDetail constructor(
data class UploadMediaDetail(
/**
* The language code ie. "en" or "fr".
* @param languageCode The language code ie. "en" or "fr".
@ -18,7 +18,7 @@ data class UploadMediaDetail constructor(
* The description text for the item being uploaded.
* @param descriptionText The description text.
*/
var descriptionText: String = "",
var descriptionText: String? = "",
/**
* The caption text for the item being uploaded.
* @param captionText The caption text.
@ -27,10 +27,10 @@ data class UploadMediaDetail constructor(
) : Parcelable {
fun javaCopy() = copy()
constructor(place: Place) : this(
place.language,
place.longDescription,
place.name,
constructor(place: Place?) : this(
place?.language,
place?.longDescription,
place?.name ?: "",
)
/**

View file

@ -1,629 +0,0 @@
package fr.free.nrw.commons.upload;
import android.app.Activity;
import android.app.Dialog;
import android.content.Intent;
import android.speech.RecognizerIntent;
import android.text.Editable;
import android.text.InputFilter;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.textfield.TextInputLayout;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.databinding.RowItemDescriptionBinding;
import fr.free.nrw.commons.recentlanguages.Language;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao;
import fr.free.nrw.commons.ui.PasteSensitiveTextInputEditText;
import fr.free.nrw.commons.utils.AbstractTextWatcher;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import timber.log.Timber;
public class UploadMediaDetailAdapter extends
RecyclerView.Adapter<UploadMediaDetailAdapter.ViewHolder> {
RecentLanguagesDao recentLanguagesDao;
private List<UploadMediaDetail> uploadMediaDetails;
private Callback callback;
private EventListener eventListener;
private HashMap<Integer, String> selectedLanguages;
private final String savedLanguageValue;
private TextView recentLanguagesTextView;
private View separator;
private ListView languageHistoryListView;
private int currentPosition;
private Fragment fragment;
private Activity activity;
private final ActivityResultLauncher<Intent> voiceInputResultLauncher;
private SelectedVoiceIcon selectedVoiceIcon;
private RowItemDescriptionBinding binding;
public UploadMediaDetailAdapter(Fragment fragment, String savedLanguageValue,
RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher<Intent> voiceInputResultLauncher) {
uploadMediaDetails = new ArrayList<>();
selectedLanguages = new HashMap<>();
this.savedLanguageValue = savedLanguageValue;
this.recentLanguagesDao = recentLanguagesDao;
this.fragment = fragment;
this.voiceInputResultLauncher = voiceInputResultLauncher;
}
public UploadMediaDetailAdapter(Activity activity, final String savedLanguageValue,
List<UploadMediaDetail> uploadMediaDetails, RecentLanguagesDao recentLanguagesDao, ActivityResultLauncher<Intent> voiceInputResultLauncher) {
this.uploadMediaDetails = uploadMediaDetails;
selectedLanguages = new HashMap<>();
this.savedLanguageValue = savedLanguageValue;
this.recentLanguagesDao = recentLanguagesDao;
this.activity = activity;
this.voiceInputResultLauncher = voiceInputResultLauncher;
}
public void setCallback(Callback callback) {
this.callback = callback;
}
public void setEventListener(EventListener eventListener) {
this.eventListener = eventListener;
}
public void setItems(List<UploadMediaDetail> uploadMediaDetails) {
this.uploadMediaDetails = uploadMediaDetails;
selectedLanguages = new HashMap<>();
notifyDataSetChanged();
}
public List<UploadMediaDetail> getItems() {
return uploadMediaDetails;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
binding = RowItemDescriptionBinding.inflate(inflater, parent, false);
return new ViewHolder(binding.getRoot());
}
/**
* This is a workaround for a known bug by android here
* https://issuetracker.google.com/issues/37095917 makes the edit text on second and subsequent
* fragments inside an adapter receptive to long click for copy/paste options
*
* @param holder the view holder
*/
@Override
public void onViewAttachedToWindow(@NonNull final ViewHolder holder) {
super.onViewAttachedToWindow(holder);
holder.captionItemEditText.setEnabled(false);
holder.captionItemEditText.setEnabled(true);
holder.descItemEditText.setEnabled(false);
holder.descItemEditText.setEnabled(true);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(position);
}
@Override
public int getItemCount() {
return uploadMediaDetails.size();
}
public void addDescription(UploadMediaDetail uploadMediaDetail) {
selectedLanguages.put(uploadMediaDetails.size(), "en");
this.uploadMediaDetails.add(uploadMediaDetail);
notifyItemInserted(uploadMediaDetails.size());
}
private void startSpeechInput(String locale) {
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
intent.putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
);
intent.putExtra(
RecognizerIntent.EXTRA_LANGUAGE,
locale
);
try {
voiceInputResultLauncher.launch(intent);
} catch (Exception e) {
Timber.e(e.getMessage());
}
}
/**
* Handles the result of the speech input by processing the spoken text.
* If the spoken text is not empty, it capitalizes the first letter of the spoken text
* and updates the appropriate field (caption or description) of the current
* UploadMediaDetail based on the selected voice icon.
* Finally, it notifies the adapter that the data set has changed.
*
* @param spokenText the text input received from speech recognition.
*/
public void handleSpeechResult(String spokenText) {
if (!spokenText.isEmpty()) {
String spokenTextCapitalized =
spokenText.substring(0, 1).toUpperCase() + spokenText.substring(1);
if (currentPosition < uploadMediaDetails.size()) {
UploadMediaDetail uploadMediaDetail = uploadMediaDetails.get(currentPosition);
switch (selectedVoiceIcon) {
case CAPTION:
uploadMediaDetail.setCaptionText(spokenTextCapitalized);
break;
case DESCRIPTION:
uploadMediaDetail.setDescriptionText(spokenTextCapitalized);
break;
}
notifyDataSetChanged();
}
}
}
/**
* Remove description based on position from the list and notifies the RecyclerView Adapter that
* data in adapter has been removed at that particular position.
*
* @param uploadMediaDetail
* @param position
*/
public void removeDescription(final UploadMediaDetail uploadMediaDetail, final int position) {
selectedLanguages.remove(position);
this.uploadMediaDetails.remove(uploadMediaDetail);
int i = position + 1;
while (selectedLanguages.containsKey(i)) {
selectedLanguages.remove(i);
i++;
}
notifyItemRemoved(position);
notifyItemRangeChanged(position, uploadMediaDetails.size() - position);
updateAddButtonVisibility();
}
public class ViewHolder extends RecyclerView.ViewHolder {
TextView descriptionLanguages ;
PasteSensitiveTextInputEditText descItemEditText;
TextInputLayout descInputLayout;
PasteSensitiveTextInputEditText captionItemEditText;
TextInputLayout captionInputLayout;
ImageView removeButton;
ImageView addButton;
ConstraintLayout clParent;
LinearLayout betterCaptionLinearLayout;
LinearLayout betterDescriptionLinearLayout;
private
AbstractTextWatcher captionListener;
AbstractTextWatcher descriptionListener;
public ViewHolder(View itemView) {
super(itemView);
Timber.i("descItemEditText:" + descItemEditText);
}
public void bind(int position) {
UploadMediaDetail uploadMediaDetail = uploadMediaDetails.get(position);
Timber.d("UploadMediaDetail is " + uploadMediaDetail);
descriptionLanguages = binding.descriptionLanguages;
descItemEditText = binding.descriptionItemEditText;
descInputLayout = binding.descriptionItemEditTextInputLayout;
captionItemEditText = binding.captionItemEditText;
captionInputLayout = binding.captionItemEditTextInputLayout;
removeButton = binding.btnRemove;
addButton = binding.btnAdd;
clParent = binding.clParent;
betterCaptionLinearLayout = binding.llWriteBetterCaption;
betterDescriptionLinearLayout = binding.llWriteBetterDescription;
descriptionLanguages.setFocusable(false);
captionItemEditText.addTextChangedListener(new AbstractTextWatcher(
value -> {
if (position == 0) {
eventListener.onPrimaryCaptionTextChange(value.length() != 0);
}
}));
captionItemEditText.removeTextChangedListener(captionListener);
descItemEditText.removeTextChangedListener(descriptionListener);
captionItemEditText.setText(uploadMediaDetail.getCaptionText());
descItemEditText.setText(uploadMediaDetail.getDescriptionText());
captionInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM);
captionInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice);
captionInputLayout.setEndIconOnClickListener(v -> {
currentPosition = position;
selectedVoiceIcon = SelectedVoiceIcon.CAPTION;
startSpeechInput(descriptionLanguages.getText().toString());
});
descInputLayout.setEndIconMode(TextInputLayout.END_ICON_CUSTOM);
descInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice);
descInputLayout.setEndIconOnClickListener(v -> {
currentPosition = position;
selectedVoiceIcon = SelectedVoiceIcon.DESCRIPTION;
startSpeechInput(descriptionLanguages.getText().toString());
});
if (position == 0) {
removeButton.setVisibility(View.GONE);
betterCaptionLinearLayout.setVisibility(View.VISIBLE);
betterCaptionLinearLayout.setOnClickListener(
v -> callback.showAlert(R.string.media_detail_caption, R.string.caption_info));
betterDescriptionLinearLayout.setVisibility(View.VISIBLE);
betterDescriptionLinearLayout.setOnClickListener(
v -> callback.showAlert(R.string.media_detail_description,
R.string.description_info));
Objects.requireNonNull(captionInputLayout.getEditText())
.setFilters(new InputFilter[]{
new UploadMediaDetailInputFilter()
});
} else {
removeButton.setVisibility(View.VISIBLE);
betterCaptionLinearLayout.setVisibility(View.GONE);
betterDescriptionLinearLayout.setVisibility(View.GONE);
}
removeButton.setOnClickListener(v -> removeDescription(uploadMediaDetail, position));
captionListener = new AbstractTextWatcher(
captionText -> uploadMediaDetail.setCaptionText(
convertIdeographicSpaceToLatinSpace(captionText.strip()))
);
descriptionListener = new AbstractTextWatcher(
descriptionText -> uploadMediaDetail.setDescriptionText(descriptionText));
captionItemEditText.addTextChangedListener(captionListener);
initLanguage(position, uploadMediaDetail);
descItemEditText.addTextChangedListener(descriptionListener);
initLanguage(position, uploadMediaDetail);
if (fragment != null) {
FrameLayout.LayoutParams newLayoutParams = (FrameLayout.LayoutParams) clParent.getLayoutParams();
newLayoutParams.topMargin = 0;
newLayoutParams.leftMargin = 0;
newLayoutParams.rightMargin = 0;
newLayoutParams.bottomMargin = 0;
clParent.setLayoutParams(newLayoutParams);
}
updateAddButtonVisibility();
addButton.setOnClickListener(v -> eventListener.addLanguage());
//If the description was manually added by the user, it deserves focus, if not, let the user decide
if (uploadMediaDetail.isManuallyAdded()) {
captionItemEditText.requestFocus();
} else {
captionItemEditText.clearFocus();
}
}
private void initLanguage(int position, UploadMediaDetail description) {
final List<Language> recentLanguages = recentLanguagesDao.getRecentLanguages();
LanguagesAdapter languagesAdapter = new LanguagesAdapter(
descriptionLanguages.getContext(),
selectedLanguages
);
descriptionLanguages.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
Dialog dialog = new Dialog(view.getContext());
dialog.setContentView(R.layout.dialog_select_language);
dialog.setCancelable(false);
dialog.getWindow().setLayout(
(int) (view.getContext().getResources().getDisplayMetrics().widthPixels
* 0.90),
(int) (view.getContext().getResources().getDisplayMetrics().heightPixels
* 0.90));
dialog.show();
EditText editText = dialog.findViewById(R.id.search_language);
ListView listView = dialog.findViewById(R.id.language_list);
languageHistoryListView = dialog.findViewById(R.id.language_history_list);
recentLanguagesTextView = dialog.findViewById(R.id.recent_searches);
separator = dialog.findViewById(R.id.separator);
setUpRecentLanguagesSection(recentLanguages);
listView.setAdapter(languagesAdapter);
editText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence charSequence, int i, int i1,
int i2) {
hideRecentLanguagesSection();
}
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1,
int i2) {
languagesAdapter.getFilter().filter(charSequence);
}
@Override
public void afterTextChanged(Editable editable) {
}
});
languageHistoryListView.setOnItemClickListener(
(adapterView, view1, position, id) -> {
onRecentLanguageClicked(dialog, adapterView, position, description);
});
listView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i,
long l) {
description.setSelectedLanguageIndex(i);
String languageCode = ((LanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(i);
description.setLanguageCode(languageCode);
final String languageName
= ((LanguagesAdapter) adapterView.getAdapter()).getLanguageName(i);
final boolean isExists
= recentLanguagesDao.findRecentLanguage(languageCode);
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode);
}
recentLanguagesDao
.addRecentLanguage(new Language(languageName, languageCode));
selectedLanguages.clear();
selectedLanguages.put(position, languageCode);
((LanguagesAdapter) adapterView
.getAdapter()).setSelectedLangCode(languageCode);
Timber.d("Description language code is: " + languageCode);
descriptionLanguages.setText(languageCode);
dialog.dismiss();
}
});
dialog.setOnDismissListener(
dialogInterface -> languagesAdapter.getFilter().filter(""));
}
});
if (description.getSelectedLanguageIndex() == -1) {
if (!TextUtils.isEmpty(savedLanguageValue)) {
// If user has chosen a default language from settings activity
// savedLanguageValue is not null
if (!TextUtils.isEmpty(description.getLanguageCode())) {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
} else {
description.setLanguageCode(savedLanguageValue);
descriptionLanguages.setText(savedLanguageValue);
selectedLanguages.remove(position);
selectedLanguages.put(position, savedLanguageValue);
}
} else if (!TextUtils.isEmpty(description.getLanguageCode())) {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
} else {
//Checking whether Language Code attribute is null or not.
if (uploadMediaDetails.get(position).getLanguageCode() != null) {
//If it is not null that means it is fetching details from the previous
// upload (i.e. when user has pressed copy previous caption & description)
//hence providing same language code for the current upload.
descriptionLanguages.setText(uploadMediaDetails.get(position)
.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, uploadMediaDetails.get(position)
.getLanguageCode());
} else {
if (position == 0) {
final int defaultLocaleIndex = languagesAdapter
.getIndexOfUserDefaultLocale(descriptionLanguages
.getContext());
descriptionLanguages
.setText(languagesAdapter.getLanguageCode(defaultLocaleIndex));
description.setLanguageCode(
languagesAdapter.getLanguageCode(defaultLocaleIndex));
selectedLanguages.remove(position);
selectedLanguages.put(position,
languagesAdapter.getLanguageCode(defaultLocaleIndex));
} else {
description.setLanguageCode(languagesAdapter.getLanguageCode(0));
descriptionLanguages.setText(languagesAdapter.getLanguageCode(0));
selectedLanguages.remove(position);
selectedLanguages.put(position, languagesAdapter.getLanguageCode(0));
}
}
}
} else {
descriptionLanguages.setText(description.getLanguageCode());
selectedLanguages.remove(position);
selectedLanguages.put(position, description.getLanguageCode());
}
}
/**
* Handles click event for recent language section
*/
private void onRecentLanguageClicked(final Dialog dialog, final AdapterView<?> adapterView,
final int position, final UploadMediaDetail description) {
description.setSelectedLanguageIndex(position);
final String languageCode = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageCode(position);
description.setLanguageCode(languageCode);
final String languageName = ((RecentLanguagesAdapter) adapterView.getAdapter())
.getLanguageName(position);
final boolean isExists = recentLanguagesDao.findRecentLanguage(languageCode);
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode);
}
recentLanguagesDao.addRecentLanguage(new Language(languageName, languageCode));
selectedLanguages.clear();
selectedLanguages.put(position, languageCode);
((RecentLanguagesAdapter) adapterView
.getAdapter()).setSelectedLangCode(languageCode);
Timber.d("Description language code is: %s", languageCode);
if (descriptionLanguages!=null) {
descriptionLanguages.setText(languageCode);
}
dialog.dismiss();
}
/**
* Hides recent languages section
*/
private void hideRecentLanguagesSection() {
languageHistoryListView.setVisibility(View.GONE);
recentLanguagesTextView.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
*/
private void setUpRecentLanguagesSection(final List<Language> recentLanguages) {
if (recentLanguages.isEmpty()) {
languageHistoryListView.setVisibility(View.GONE);
recentLanguagesTextView.setVisibility(View.GONE);
separator.setVisibility(View.GONE);
} else {
if (recentLanguages.size() > 5) {
for (int i = recentLanguages.size() - 1; i >= 5; i--) {
recentLanguagesDao.deleteRecentLanguage(recentLanguages.get(i)
.getLanguageCode());
}
}
languageHistoryListView.setVisibility(View.VISIBLE);
recentLanguagesTextView.setVisibility(View.VISIBLE);
separator.setVisibility(View.VISIBLE);
if (descriptionLanguages!=null) {
final RecentLanguagesAdapter recentLanguagesAdapter
= new RecentLanguagesAdapter(
descriptionLanguages.getContext(),
recentLanguagesDao.getRecentLanguages(),
selectedLanguages);
languageHistoryListView.setAdapter(recentLanguagesAdapter);
}
}
}
/**
* Convert Ideographic space to Latin space
*
* @param source the source text
* @return a string with Latin spaces instead of Ideographic spaces
*/
public String convertIdeographicSpaceToLatinSpace(String source) {
Pattern ideographicSpacePattern = Pattern.compile("\\x{3000}");
return ideographicSpacePattern.matcher(source).replaceAll(" ");
}
}
/**
* Hides the visibility of the "Add" button for all items in the RecyclerView except
* the last item in RecyclerView
*/
private void updateAddButtonVisibility() {
int lastItemPosition = getItemCount() - 1;
// Hide Add Button for all items
for (int i = 0; i < getItemCount(); i++) {
if (fragment != null) {
if (fragment.getView() != null) {
ViewHolder holder = (ViewHolder) ((RecyclerView) fragment.getView()
.findViewById(R.id.rv_descriptions)).findViewHolderForAdapterPosition(i);
if (holder != null) {
holder.addButton.setVisibility(View.GONE);
}
}
} else {
if (this.activity != null) {
ViewHolder holder = (ViewHolder) ((RecyclerView) activity.findViewById(
R.id.rv_descriptions_captions)).findViewHolderForAdapterPosition(i);
if (holder != null) {
holder.addButton.setVisibility(View.GONE);
}
}
}
}
// Show Add Button for the last item
if (fragment != null) {
if (fragment.getView() != null) {
ViewHolder lastItemHolder = (ViewHolder) ((RecyclerView) fragment.getView()
.findViewById(R.id.rv_descriptions)).findViewHolderForAdapterPosition(
lastItemPosition);
if (lastItemHolder != null) {
lastItemHolder.addButton.setVisibility(View.VISIBLE);
}
}
} else {
if (this.activity != null) {
ViewHolder lastItemHolder = (ViewHolder) ((RecyclerView) activity
.findViewById(R.id.rv_descriptions_captions)).findViewHolderForAdapterPosition(
lastItemPosition);
if (lastItemHolder != null) {
lastItemHolder.addButton.setVisibility(View.VISIBLE);
}
}
}
}
public interface Callback {
void showAlert(int mediaDetailDescription, int descriptionInfo);
}
public interface EventListener {
void onPrimaryCaptionTextChange(boolean isNotEmpty);
void addLanguage();
}
enum SelectedVoiceIcon {
CAPTION,
DESCRIPTION
}
}

View file

@ -0,0 +1,563 @@
package fr.free.nrw.commons.upload
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.speech.RecognizerIntent.ACTION_RECOGNIZE_SPEECH
import android.speech.RecognizerIntent.EXTRA_LANGUAGE
import android.speech.RecognizerIntent.EXTRA_LANGUAGE_MODEL
import android.speech.RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
import android.text.Editable
import android.text.InputFilter
import android.text.TextUtils
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.RowItemDescriptionBinding
import fr.free.nrw.commons.recentlanguages.Language
import fr.free.nrw.commons.recentlanguages.RecentLanguagesAdapter
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.utils.AbstractTextWatcher
import timber.log.Timber
import java.util.Locale
import java.util.regex.Pattern
class UploadMediaDetailAdapter : RecyclerView.Adapter<UploadMediaDetailAdapter.ViewHolder> {
private var uploadMediaDetails: MutableList<UploadMediaDetail>
private var selectedLanguages: MutableMap<Int, String>
private val savedLanguageValue: String
private var recentLanguagesTextView: TextView? = null
private var separator: View? = null
private var languageHistoryListView: ListView? = null
private var currentPosition = 0
private var fragment: Fragment? = null
private var activity: Activity? = null
private val voiceInputResultLauncher: ActivityResultLauncher<Intent>
private var selectedVoiceIcon: SelectedVoiceIcon? = null
var recentLanguagesDao: RecentLanguagesDao
var callback: Callback? = null
var eventListener: EventListener? = null
var items: List<UploadMediaDetail>
get() = uploadMediaDetails
set(value) {
uploadMediaDetails = value.toMutableList()
selectedLanguages = mutableMapOf()
notifyDataSetChanged()
}
constructor(
fragment: Fragment?,
savedLanguageValue: String,
recentLanguagesDao: RecentLanguagesDao,
voiceInputResultLauncher: ActivityResultLauncher<Intent>
) {
uploadMediaDetails = ArrayList()
selectedLanguages = mutableMapOf()
this.savedLanguageValue = savedLanguageValue
this.recentLanguagesDao = recentLanguagesDao
this.fragment = fragment
this.voiceInputResultLauncher = voiceInputResultLauncher
}
constructor(
activity: Activity?,
savedLanguageValue: String,
uploadMediaDetails: MutableList<UploadMediaDetail>,
recentLanguagesDao: RecentLanguagesDao,
voiceInputResultLauncher: ActivityResultLauncher<Intent>
) {
this.uploadMediaDetails = uploadMediaDetails
selectedLanguages = HashMap()
this.savedLanguageValue = savedLanguageValue
this.recentLanguagesDao = recentLanguagesDao
this.activity = activity
this.voiceInputResultLauncher = voiceInputResultLauncher
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return ViewHolder(RowItemDescriptionBinding.inflate(inflater, parent, false))
}
/**
* This is a workaround for a known bug by android here
* https://issuetracker.google.com/issues/37095917 makes the edit text on second and subsequent
* fragments inside an adapter receptive to long click for copy/paste options
*
* @param holder the view holder
*/
override fun onViewAttachedToWindow(holder: ViewHolder) {
super.onViewAttachedToWindow(holder)
holder.binding.captionItemEditText.isEnabled = false
holder.binding.captionItemEditText.isEnabled = true
holder.binding.descriptionItemEditText.isEnabled = false
holder.binding.descriptionItemEditText.isEnabled = true
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount(): Int {
return uploadMediaDetails.size
}
fun addDescription(uploadMediaDetail: UploadMediaDetail) {
selectedLanguages[uploadMediaDetails.size] = "en"
uploadMediaDetails.add(uploadMediaDetail)
notifyItemInserted(uploadMediaDetails.size)
}
private fun startSpeechInput(locale: String) {
try {
voiceInputResultLauncher.launch(Intent(ACTION_RECOGNIZE_SPEECH).apply {
putExtra(EXTRA_LANGUAGE_MODEL, LANGUAGE_MODEL_FREE_FORM)
putExtra(EXTRA_LANGUAGE, locale)
})
} catch (e: Exception) {
Timber.e(e)
}
}
/**
* Handles the result of the speech input by processing the spoken text.
* If the spoken text is not empty, it capitalizes the first letter of the spoken text
* and updates the appropriate field (caption or description) of the current
* UploadMediaDetail based on the selected voice icon.
* Finally, it notifies the adapter that the data set has changed.
*
* @param spokenText the text input received from speech recognition.
*/
fun handleSpeechResult(spokenText: String) {
if (spokenText.isNotEmpty()) {
val spokenTextCapitalized =
spokenText.substring(0, 1).uppercase(Locale.getDefault()) + spokenText.substring(1)
if (currentPosition < uploadMediaDetails.size) {
val uploadMediaDetail = uploadMediaDetails[currentPosition]
when (selectedVoiceIcon) {
SelectedVoiceIcon.CAPTION -> uploadMediaDetail.captionText =
spokenTextCapitalized
SelectedVoiceIcon.DESCRIPTION -> uploadMediaDetail.descriptionText =
spokenTextCapitalized
null -> {}
}
notifyDataSetChanged()
}
}
}
/**
* Remove description based on position from the list and notifies the RecyclerView Adapter that
* data in adapter has been removed at that particular position.
*/
fun removeDescription(uploadMediaDetail: UploadMediaDetail, position: Int) {
selectedLanguages.remove(position)
uploadMediaDetails.remove(uploadMediaDetail)
var i = position + 1
while (selectedLanguages.containsKey(i)) {
selectedLanguages.remove(i)
i++
}
notifyItemRemoved(position)
notifyItemRangeChanged(position, uploadMediaDetails.size - position)
updateAddButtonVisibility()
}
inner class ViewHolder(val binding: RowItemDescriptionBinding) :
RecyclerView.ViewHolder(binding.root) {
var addButton: ImageView? = null
var clParent: ConstraintLayout? = null
var betterCaptionLinearLayout: LinearLayout? = null
var betterDescriptionLinearLayout: LinearLayout? = null
private var captionListener: AbstractTextWatcher? = null
var descriptionListener: AbstractTextWatcher? = null
fun bind(position: Int) {
val uploadMediaDetail = uploadMediaDetails[position]
Timber.d("UploadMediaDetail is %s", uploadMediaDetail)
addButton = binding.btnAdd
clParent = binding.clParent
betterCaptionLinearLayout = binding.llWriteBetterCaption
betterDescriptionLinearLayout = binding.llWriteBetterDescription
binding.descriptionLanguages.isFocusable = false
binding.captionItemEditText.addTextChangedListener(AbstractTextWatcher { value: String ->
if (position == 0) {
eventListener!!.onPrimaryCaptionTextChange(value.length != 0)
}
})
binding.captionItemEditText.removeTextChangedListener(captionListener)
binding.descriptionItemEditText.removeTextChangedListener(descriptionListener)
binding.captionItemEditText.setText(uploadMediaDetail.captionText)
binding.descriptionItemEditText.setText(uploadMediaDetail.descriptionText)
binding.captionItemEditTextInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.captionItemEditTextInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice)
binding.captionItemEditTextInputLayout.setEndIconOnClickListener { v: View? ->
currentPosition = position
selectedVoiceIcon = SelectedVoiceIcon.CAPTION
startSpeechInput(binding.descriptionLanguages.text.toString())
}
binding.descriptionItemEditTextInputLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
binding.descriptionItemEditTextInputLayout.setEndIconDrawable(R.drawable.baseline_keyboard_voice)
binding.descriptionItemEditTextInputLayout.setEndIconOnClickListener { v: View? ->
currentPosition = position
selectedVoiceIcon = SelectedVoiceIcon.DESCRIPTION
startSpeechInput(binding.descriptionLanguages.text.toString())
}
if (position == 0) {
binding.btnRemove.visibility = View.GONE
betterCaptionLinearLayout!!.visibility = View.VISIBLE
betterCaptionLinearLayout!!.setOnClickListener { v: View? ->
callback!!.showAlert(
R.string.media_detail_caption,
R.string.caption_info
)
}
betterDescriptionLinearLayout!!.visibility = View.VISIBLE
betterDescriptionLinearLayout!!.setOnClickListener { v: View? ->
callback!!.showAlert(
R.string.media_detail_description,
R.string.description_info
)
}
binding.captionItemEditTextInputLayout.editText?.let {
it.filters = arrayOf<InputFilter>(UploadMediaDetailInputFilter())
}
} else {
binding.btnRemove.visibility = View.VISIBLE
betterCaptionLinearLayout!!.visibility = View.GONE
betterDescriptionLinearLayout!!.visibility = View.GONE
}
binding.btnRemove.setOnClickListener { v: View? ->
removeDescription(
uploadMediaDetail,
position
)
}
captionListener = AbstractTextWatcher { captionText: String ->
uploadMediaDetail.captionText =
convertIdeographicSpaceToLatinSpace(captionText.trim())
}
descriptionListener = AbstractTextWatcher { value: String? ->
uploadMediaDetail.descriptionText = value
}
binding.captionItemEditText.addTextChangedListener(captionListener)
initLanguage(position, uploadMediaDetail)
binding.descriptionItemEditText.addTextChangedListener(descriptionListener)
initLanguage(position, uploadMediaDetail)
if (fragment != null) {
val newLayoutParams = clParent!!.layoutParams as FrameLayout.LayoutParams
newLayoutParams.topMargin = 0
newLayoutParams.leftMargin = 0
newLayoutParams.rightMargin = 0
newLayoutParams.bottomMargin = 0
clParent!!.layoutParams = newLayoutParams
}
updateAddButtonVisibility()
addButton!!.setOnClickListener { v: View? -> eventListener!!.addLanguage() }
//If the description was manually added by the user, it deserves focus, if not, let the user decide
if (uploadMediaDetail.isManuallyAdded) {
binding.captionItemEditText.requestFocus()
} else {
binding.captionItemEditText.clearFocus()
}
}
private fun initLanguage(position: Int, description: UploadMediaDetail) {
val recentLanguages = recentLanguagesDao.getRecentLanguages()
val languagesAdapter = LanguagesAdapter(
binding.descriptionLanguages.context,
selectedLanguages
)
binding.descriptionLanguages.setOnClickListener { view ->
val dialog = Dialog(view.context)
dialog.setContentView(R.layout.dialog_select_language)
dialog.setCancelable(false)
dialog.window!!.setLayout(
(view.context.resources.displayMetrics.widthPixels
* 0.90).toInt(),
(view.context.resources.displayMetrics.heightPixels
* 0.90).toInt()
)
dialog.show()
val editText =
dialog.findViewById<EditText>(R.id.search_language)
val listView =
dialog.findViewById<ListView>(R.id.language_list)
languageHistoryListView =
dialog.findViewById(R.id.language_history_list)
recentLanguagesTextView =
dialog.findViewById(R.id.recent_searches)
separator =
dialog.findViewById(R.id.separator)
setUpRecentLanguagesSection(recentLanguages)
listView.adapter = languagesAdapter
editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) =
hideRecentLanguagesSection()
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {
languagesAdapter.filter.filter(charSequence)
}
override fun afterTextChanged(editable: Editable) = Unit
})
languageHistoryListView?.setOnItemClickListener { adapterView: AdapterView<*>, view1: View?, position: Int, id: Long ->
onRecentLanguageClicked(dialog, adapterView, position, description)
}
listView.onItemClickListener = OnItemClickListener { adapterView, _, i, l ->
description.selectedLanguageIndex = i
val languageCode = (adapterView.adapter as LanguagesAdapter).getLanguageCode(i)
description.languageCode = languageCode
val languageName = (adapterView.adapter as LanguagesAdapter).getLanguageName(i)
val isExists = recentLanguagesDao.findRecentLanguage(languageCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode)
}
recentLanguagesDao.addRecentLanguage(Language(languageName, languageCode))
selectedLanguages.clear()
selectedLanguages[position] = languageCode
(adapterView.adapter as LanguagesAdapter).selectedLangCode = languageCode
Timber.d("Description language code is: %s", languageCode)
binding.descriptionLanguages.text = languageCode
dialog.dismiss()
}
dialog.setOnDismissListener {
languagesAdapter.filter.filter("")
}
}
if (description.selectedLanguageIndex == -1) {
if (!TextUtils.isEmpty(savedLanguageValue)) {
// If user has chosen a default language from settings activity
// savedLanguageValue is not null
if (!TextUtils.isEmpty(description.languageCode)) {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = description.languageCode!!
} else {
description.languageCode = savedLanguageValue
binding.descriptionLanguages.text = savedLanguageValue
selectedLanguages.remove(position)
selectedLanguages[position] = savedLanguageValue
}
} else if (!TextUtils.isEmpty(description.languageCode)) {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = description.languageCode!!
} else {
//Checking whether Language Code attribute is null or not.
if (uploadMediaDetails[position].languageCode != null) {
//If it is not null that means it is fetching details from the previous
// upload (i.e. when user has pressed copy previous caption & description)
//hence providing same language code for the current upload.
binding.descriptionLanguages.text = uploadMediaDetails[position]
.languageCode
selectedLanguages.remove(position)
selectedLanguages[position] = uploadMediaDetails[position].languageCode!!
} else {
if (position == 0) {
val defaultLocaleIndex = languagesAdapter.getIndexOfUserDefaultLocale(
binding.descriptionLanguages.getContext())
binding.descriptionLanguages.setText(languagesAdapter.getLanguageCode(defaultLocaleIndex))
description.languageCode = languagesAdapter.getLanguageCode(defaultLocaleIndex)
selectedLanguages.remove(position)
selectedLanguages[position] =
languagesAdapter.getLanguageCode(defaultLocaleIndex)
} else {
description.languageCode = languagesAdapter.getLanguageCode(0)
binding.descriptionLanguages.text = languagesAdapter.getLanguageCode(0)
selectedLanguages.remove(position)
selectedLanguages[position] = languagesAdapter.getLanguageCode(0)
}
}
}
} else {
binding.descriptionLanguages.text = description.languageCode
selectedLanguages.remove(position)
description.languageCode?.let {
selectedLanguages[position] = it
}
}
}
/**
* Handles click event for recent language section
*/
private fun onRecentLanguageClicked(
dialog: Dialog, adapterView: AdapterView<*>,
position: Int, description: UploadMediaDetail
) {
description.selectedLanguageIndex = position
val languageCode = (adapterView.adapter as RecentLanguagesAdapter)
.getLanguageCode(position)
description.languageCode = languageCode
val languageName = (adapterView.adapter as RecentLanguagesAdapter)
.getLanguageName(position)
val isExists = recentLanguagesDao.findRecentLanguage(languageCode)
if (isExists) {
recentLanguagesDao.deleteRecentLanguage(languageCode)
}
recentLanguagesDao.addRecentLanguage(Language(languageName, languageCode))
selectedLanguages.clear()
selectedLanguages[position] = languageCode
(adapterView
.adapter as RecentLanguagesAdapter).selectedLangCode = languageCode
Timber.d("Description language code is: %s", languageCode)
binding.descriptionLanguages.text = languageCode
dialog.dismiss()
}
/**
* Hides recent languages section
*/
private fun hideRecentLanguagesSection() {
languageHistoryListView!!.visibility = View.GONE
recentLanguagesTextView!!.visibility = View.GONE
separator!!.visibility = View.GONE
}
/**
* Set up recent languages section
*
* @param recentLanguages recently used languages
*/
private fun setUpRecentLanguagesSection(recentLanguages: List<Language>) {
if (recentLanguages.isEmpty()) {
languageHistoryListView!!.visibility = View.GONE
recentLanguagesTextView!!.visibility = View.GONE
separator!!.visibility = View.GONE
} else {
if (recentLanguages.size > 5) {
for (i in recentLanguages.size - 1 downTo 5) {
recentLanguagesDao.deleteRecentLanguage(
recentLanguages[i]
.languageCode
)
}
}
languageHistoryListView!!.visibility = View.VISIBLE
recentLanguagesTextView!!.visibility = View.VISIBLE
separator!!.visibility = View.VISIBLE
val recentLanguagesAdapter = RecentLanguagesAdapter(
binding.descriptionLanguages.context,
recentLanguagesDao.getRecentLanguages(),
selectedLanguages
)
languageHistoryListView!!.adapter = recentLanguagesAdapter
}
}
/**
* Convert Ideographic space to Latin space
*
* @param source the source text
* @return a string with Latin spaces instead of Ideographic spaces
*/
fun convertIdeographicSpaceToLatinSpace(source: String): String {
val ideographicSpacePattern = Pattern.compile("\\x{3000}")
return ideographicSpacePattern.matcher(source).replaceAll(" ")
}
}
/**
* Hides the visibility of the "Add" button for all items in the RecyclerView except
* the last item in RecyclerView
*/
private fun updateAddButtonVisibility() {
val lastItemPosition = itemCount - 1
// Hide Add Button for all items
for (i in 0 until itemCount) {
if (fragment != null) {
if (fragment!!.view != null) {
val holder = (fragment!!.requireView().findViewById<View>(R.id.rv_descriptions) as RecyclerView).findViewHolderForAdapterPosition(i) as ViewHolder?
if (holder != null) {
holder.addButton!!.visibility = View.GONE
}
}
} else {
if (activity != null) {
val holder = (activity!!.findViewById<View>(R.id.rv_descriptions_captions) as RecyclerView).findViewHolderForAdapterPosition(i) as ViewHolder?
if (holder != null) {
holder.addButton!!.visibility = View.GONE
}
}
}
}
// Show Add Button for the last item
if (fragment != null) {
if (fragment!!.view != null) {
val lastItemHolder = (fragment!!.requireView().findViewById<View>(R.id.rv_descriptions) as RecyclerView).findViewHolderForAdapterPosition(lastItemPosition) as ViewHolder?
if (lastItemHolder != null) {
lastItemHolder.addButton!!.visibility = View.VISIBLE
}
}
} else {
if (activity != null) {
val lastItemHolder = (activity!!.findViewById<View>(R.id.rv_descriptions_captions) as RecyclerView).findViewHolderForAdapterPosition(lastItemPosition) as ViewHolder?
if (lastItemHolder != null) {
lastItemHolder.addButton!!.visibility = View.VISIBLE
}
}
}
}
fun interface Callback {
fun showAlert(mediaDetailDescription: Int, descriptionInfo: Int)
}
interface EventListener {
fun onPrimaryCaptionTextChange(isNotEmpty: Boolean)
fun addLanguage()
}
internal enum class SelectedVoiceIcon {
CAPTION,
DESCRIPTION
}
}

View file

@ -1,297 +0,0 @@
package fr.free.nrw.commons.upload;
import android.content.Context;
import android.net.Uri;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.auth.SessionManager;
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.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.disposables.CompositeDisposable;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.jetbrains.annotations.NotNull;
import timber.log.Timber;
@Singleton
public class UploadModel {
private final JsonKvStore store;
private final List<String> licenses;
private final Context context;
private String license;
private final Map<String, String> licensesByName;
private final List<UploadItem> items = new ArrayList<>();
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
private final SessionManager sessionManager;
private final FileProcessor fileProcessor;
private final ImageProcessingService imageProcessingService;
private final List<String> selectedCategories = new ArrayList<>();
private final List<DepictedItem> selectedDepictions = new ArrayList<>();
/**
* Existing depicts which are selected
*/
private List<String> selectedExistingDepictions = new ArrayList<>();
@Inject
UploadModel(@Named("licenses") final List<String> licenses,
@Named("default_preferences") final JsonKvStore store,
@Named("licenses_by_name") final Map<String, String> licensesByName,
final Context context,
final SessionManager sessionManager,
final FileProcessor fileProcessor,
final ImageProcessingService imageProcessingService) {
this.licenses = licenses;
this.store = store;
this.license = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3);
this.licensesByName = licensesByName;
this.context = context;
this.sessionManager = sessionManager;
this.fileProcessor = fileProcessor;
this.imageProcessingService = imageProcessingService;
}
/**
* cleanup the resources, I am Singleton, preparing for fresh upload
*/
public void cleanUp() {
compositeDisposable.clear();
fileProcessor.cleanup();
items.clear();
selectedCategories.clear();
selectedDepictions.clear();
selectedExistingDepictions.clear();
}
public void setSelectedCategories(List<String> selectedCategories) {
this.selectedCategories.clear();
this.selectedCategories.addAll(selectedCategories);
}
/**
* pre process a one item at a time
*/
public Observable<UploadItem> preProcessImage(final UploadableFile uploadableFile,
final Place place,
final SimilarImageInterface similarImageInterface,
LatLng inAppPictureLocation) {
return Observable.just(
createAndAddUploadItem(uploadableFile, place, similarImageInterface, inAppPictureLocation));
}
/**
* Calls validateImage() of ImageProcessingService to check quality of image
*
* @param uploadItem UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @return Quality of UploadItem
*/
public Single<Integer> getImageQuality(final UploadItem uploadItem, LatLng inAppPictureLocation) {
return imageProcessingService.validateImage(uploadItem, inAppPictureLocation);
}
/**
* Calls checkDuplicateImage() of ImageProcessingService to check if image is duplicate
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
public Single<Integer> checkDuplicateImage(String filePath){
return imageProcessingService.checkDuplicateImage(filePath);
}
/**
* Calls validateCaption() of ImageProcessingService to check caption of image
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
public Single<Integer> getCaptionQuality(final UploadItem uploadItem) {
return imageProcessingService.validateCaption(uploadItem);
}
private UploadItem createAndAddUploadItem(final UploadableFile uploadableFile,
final Place place,
final SimilarImageInterface similarImageInterface,
LatLng inAppPictureLocation) {
final UploadableFile.DateTimeWithSource dateTimeWithSource = uploadableFile
.getFileCreatedDate(context);
long fileCreatedDate = -1;
String createdTimestampSource = "";
String fileCreatedDateString = "";
if (dateTimeWithSource != null) {
fileCreatedDate = dateTimeWithSource.getEpochDate();
fileCreatedDateString = dateTimeWithSource.getDateString();
createdTimestampSource = dateTimeWithSource.getSource();
}
Timber.d("File created date is %d", fileCreatedDate);
final ImageCoordinates imageCoordinates = fileProcessor
.processFileCoordinates(similarImageInterface, uploadableFile.getFilePath(),
inAppPictureLocation);
final UploadItem uploadItem = new UploadItem(
Uri.parse(uploadableFile.getFilePath()),
uploadableFile.getMimeType(context), imageCoordinates, place, fileCreatedDate,
createdTimestampSource,
uploadableFile.getContentUri(),
fileCreatedDateString);
// If an uploadItem of the same uploadableFile has been created before, we return that.
// This is to avoid multiple instances of uploadItem of same file passed around.
if (items.contains(uploadItem)) {
return items.get(items.indexOf(uploadItem));
}
if (place != null) {
uploadItem.getUploadMediaDetails().set(0, new UploadMediaDetail(place));
}
if (!items.contains(uploadItem)) {
items.add(uploadItem);
}
return uploadItem;
}
public int getCount() {
return items.size();
}
public List<UploadItem> getUploads() {
return items;
}
public List<String> getLicenses() {
return licenses;
}
public String getSelectedLicense() {
return license;
}
public void setSelectedLicense(final String licenseName) {
this.license = licensesByName.get(licenseName);
store.putString(Prefs.DEFAULT_LICENSE, license);
}
public Observable<Contribution> buildContributions() {
return Observable.fromIterable(items).map(item ->
{
String imageSHA1 = FileUtils.INSTANCE.getSHA1(context.getContentResolver().openInputStream(item.getContentUri()));
final Contribution contribution = new Contribution(
item, sessionManager, newListOf(selectedDepictions), newListOf(selectedCategories), imageSHA1);
contribution.setHasInvalidLocation(item.hasInvalidLocation());
Timber.d("Created timestamp while building contribution is %s, %s",
item.getCreatedTimestamp(),
new Date(item.getCreatedTimestamp()));
if (item.getCreatedTimestamp() != -1L) {
contribution.setDateCreated(new Date(item.getCreatedTimestamp()));
contribution.setDateCreatedSource(item.getCreatedTimestampSource());
//Set the date only if you have it, else the upload service is gonna try it the other way
}
if (contribution.getWikidataPlace() != null) {
if (item.isWLMUpload()) {
contribution.getWikidataPlace().setMonumentUpload(true);
} else {
contribution.getWikidataPlace().setMonumentUpload(false);
}
}
contribution.setCountryCode(item.getCountryCode());
return contribution;
});
}
public void deletePicture(final String filePath) {
final Iterator<UploadItem> iterator = items.iterator();
while (iterator.hasNext()) {
if (iterator.next().getMediaUri().toString().contains(filePath)) {
iterator.remove();
break;
}
}
if (items.isEmpty()) {
cleanUp();
}
}
public List<UploadItem> getItems() {
return items;
}
public void onDepictItemClicked(DepictedItem depictedItem, Media media) {
if (media == null) {
if (depictedItem.isSelected()) {
selectedDepictions.add(depictedItem);
} else {
selectedDepictions.remove(depictedItem);
}
} else {
if (depictedItem.isSelected()) {
if (media.getDepictionIds().contains(depictedItem.getId())) {
selectedExistingDepictions.add(depictedItem.getId());
} else {
selectedDepictions.add(depictedItem);
}
} else {
if (media.getDepictionIds().contains(depictedItem.getId())) {
selectedExistingDepictions.remove(depictedItem.getId());
if (!media.getDepictionIds().contains(depictedItem.getId())) {
final List<String> depictsList = new ArrayList<>();
depictsList.add(depictedItem.getId());
depictsList.addAll(media.getDepictionIds());
media.setDepictionIds(depictsList);
}
} else {
selectedDepictions.remove(depictedItem);
}
}
}
}
@NotNull
private <T> List<T> newListOf(final List<T> items) {
return items != null ? new ArrayList<>(items) : new ArrayList<>();
}
public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) {
fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates);
items.get(uploadItemIndex).setGpsCoords(imageCoordinates);
}
public List<DepictedItem> getSelectedDepictions() {
return selectedDepictions;
}
/**
* Provides selected existing depicts
*
* @return selected existing depicts
*/
public List<String> getSelectedExistingDepictions() {
return selectedExistingDepictions;
}
/**
* Initialize existing depicts
*
* @param selectedExistingDepictions existing depicts
*/
public void setSelectedExistingDepictions(final List<String> selectedExistingDepictions) {
this.selectedExistingDepictions = selectedExistingDepictions;
}
}

View file

@ -0,0 +1,242 @@
package fr.free.nrw.commons.upload
import android.content.Context
import android.net.Uri
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.auth.SessionManager
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.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.FileUtils.getSHA1
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import java.util.Date
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class UploadModel @Inject internal constructor(
@param:Named("licenses") val licenses: List<String>,
@param:Named("default_preferences") val store: JsonKvStore,
@param:Named("licenses_by_name") val licensesByName: Map<String, String>,
val context: Context,
val sessionManager: SessionManager,
val fileProcessor: FileProcessor,
val imageProcessingService: ImageProcessingService
) {
var license: String? = store.getString(Prefs.DEFAULT_LICENSE, Prefs.Licenses.CC_BY_SA_3)
val items: MutableList<UploadItem> = mutableListOf()
val compositeDisposable: CompositeDisposable = CompositeDisposable()
val selectedCategories: MutableList<String> = mutableListOf()
val selectedDepictions: MutableList<DepictedItem> = mutableListOf()
/**
* Existing depicts which are selected
*/
var selectedExistingDepictions: MutableList<String> = mutableListOf()
val count: Int
get() = items.size
val uploads: List<UploadItem>
get() = items
var selectedLicense: String?
get() = license
set(licenseName) {
license = licensesByName[licenseName]
if (license == null) {
store.remove(Prefs.DEFAULT_LICENSE)
} else {
store.putString(Prefs.DEFAULT_LICENSE, license!!)
}
}
/**
* cleanup the resources, I am Singleton, preparing for fresh upload
*/
fun cleanUp() {
compositeDisposable.clear()
fileProcessor.cleanup()
items.clear()
selectedCategories.clear()
selectedDepictions.clear()
selectedExistingDepictions.clear()
}
fun setSelectedCategories(categories: List<String>) {
selectedCategories.clear()
selectedCategories.addAll(categories)
}
/**
* pre process a one item at a time
*/
fun preProcessImage(
uploadableFile: UploadableFile?,
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): Observable<UploadItem> = Observable.just(
createAndAddUploadItem(uploadableFile, place, similarImageInterface, inAppPictureLocation)
)
/**
* Calls validateImage() of ImageProcessingService to check quality of image
*
* @param uploadItem UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @return Quality of UploadItem
*/
fun getImageQuality(uploadItem: UploadItem, inAppPictureLocation: LatLng?): Single<Int> =
imageProcessingService.validateImage(uploadItem, inAppPictureLocation)
/**
* Calls checkDuplicateImage() of ImageProcessingService to check if image is duplicate
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
fun checkDuplicateImage(filePath: String?): Single<Int> =
imageProcessingService.checkDuplicateImage(filePath)
/**
* Calls validateCaption() of ImageProcessingService to check caption of image
*
* @param uploadItem UploadItem whose caption is to be checked
* @return Quality of caption of the UploadItem
*/
fun getCaptionQuality(uploadItem: UploadItem): Single<Int> =
imageProcessingService.validateCaption(uploadItem)
private fun createAndAddUploadItem(
uploadableFile: UploadableFile?,
place: Place?,
similarImageInterface: SimilarImageInterface?,
inAppPictureLocation: LatLng?
): UploadItem {
val dateTimeWithSource = uploadableFile?.getFileCreatedDate(context)
var fileCreatedDate: Long = -1
var createdTimestampSource = ""
var fileCreatedDateString: String? = ""
if (dateTimeWithSource != null) {
fileCreatedDate = dateTimeWithSource.epochDate
fileCreatedDateString = dateTimeWithSource.dateString
createdTimestampSource = dateTimeWithSource.source
}
Timber.d("File created date is %d", fileCreatedDate)
val imageCoordinates = fileProcessor
.processFileCoordinates(
similarImageInterface, uploadableFile?.getFilePath(),
inAppPictureLocation
)
val uploadItem = UploadItem(
Uri.parse(uploadableFile?.getFilePath()),
uploadableFile?.getMimeType(context), imageCoordinates, place, fileCreatedDate,
createdTimestampSource,
uploadableFile?.contentUri,
fileCreatedDateString
)
// If an uploadItem of the same uploadableFile has been created before, we return that.
// This is to avoid multiple instances of uploadItem of same file passed around.
if (items.contains(uploadItem)) {
return items[items.indexOf(uploadItem)]
}
uploadItem.uploadMediaDetails[0] = UploadMediaDetail(place)
if (!items.contains(uploadItem)) {
items.add(uploadItem)
}
return uploadItem
}
fun buildContributions(): Observable<Contribution> {
return Observable.fromIterable(items).map { item: UploadItem ->
val imageSHA1 = getSHA1(
context.contentResolver.openInputStream(item.contentUri!!)!!
)
val contribution = Contribution(
item,
sessionManager,
buildList { addAll(selectedDepictions) },
buildList { addAll(selectedCategories) },
imageSHA1
)
contribution.setHasInvalidLocation(item.hasInvalidLocation())
Timber.d(
"Created timestamp while building contribution is %s, %s",
item.createdTimestamp,
item.createdTimestamp?.let { Date(it) }
)
if (item.createdTimestamp != -1L) {
contribution.dateCreated = item.createdTimestamp?.let { Date(it) }
contribution.dateCreatedSource = item.createdTimestampSource
//Set the date only if you have it, else the upload service is gonna try it the other way
}
if (contribution.wikidataPlace != null) {
contribution.wikidataPlace!!.isMonumentUpload = item.isWLMUpload
}
contribution.countryCode = item.countryCode
contribution
}
}
fun deletePicture(filePath: String) {
val iterator = items.iterator()
while (iterator.hasNext()) {
if (iterator.next().mediaUri.toString().contains(filePath)) {
iterator.remove()
break
}
}
if (items.isEmpty()) {
cleanUp()
}
}
fun onDepictItemClicked(depictedItem: DepictedItem, media: Media?) {
if (media == null) {
if (depictedItem.isSelected) {
selectedDepictions.add(depictedItem)
} else {
selectedDepictions.remove(depictedItem)
}
} else {
if (depictedItem.isSelected) {
if (media.depictionIds.contains(depictedItem.id)) {
selectedExistingDepictions.add(depictedItem.id)
} else {
selectedDepictions.add(depictedItem)
}
} else {
if (media.depictionIds.contains(depictedItem.id)) {
selectedExistingDepictions.remove(depictedItem.id)
if (!media.depictionIds.contains(depictedItem.id)) {
media.depictionIds = mutableListOf<String>().apply {
add(depictedItem.id)
addAll(media.depictionIds)
}
}
} else {
selectedDepictions.remove(depictedItem)
}
}
}
}
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int) {
fileProcessor.prePopulateCategoriesAndDepictionsBy(imageCoordinates)
items[uploadItemIndex].gpsCoords = imageCoordinates
}
}

View file

@ -1,10 +1,10 @@
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.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract
@ -34,6 +34,8 @@ class UploadPresenter @Inject internal constructor(
private val compositeDisposable = CompositeDisposable()
lateinit var basicKvStoreFactory: (String) -> BasicKvStore
/**
* Called by the submit button in [UploadActivity]
*/
@ -69,8 +71,7 @@ class UploadPresenter @Inject internal constructor(
private fun processContributionsForSubmission() {
if (view.isLoggedIn()) {
view.showProgress(true)
repository.buildContributions()
?.observeOn(Schedulers.io())
repository.buildContributions().observeOn(Schedulers.io())
?.subscribe(object : Observer<Contribution> {
override fun onSubscribe(d: Disposable) {
view.showProgress(false)
@ -127,14 +128,20 @@ class UploadPresenter @Inject internal constructor(
}
}
override fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore) {
basicKvStoreFactory = factory
}
/**
* 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)
repository.getUploadItem(uploadItemIndex)?.let {
presenter.setupBasicKvStoreFactory(basicKvStoreFactory)
presenter.checkImageQuality(it, uploadItemIndex)
}
}
override fun deletePictureAtIndex(index: Int) {
@ -156,8 +163,9 @@ class UploadPresenter @Inject internal constructor(
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)
repository.getUploadItem(index)?.let {
presenter.checkImageQuality(it, index)
}
}
if (uploadableFiles.size < 2) {

View file

@ -95,12 +95,10 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
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)
)
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)
@ -220,7 +218,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
}
override fun goToNextScreen() {
callback!!.onNextButtonClicked(callback!!.getIndexInViewFlipper(this))
callback?.let { it.onNextButtonClicked(it.getIndexInViewFlipper(this)) }
}
override fun showNoCategorySelected() {
@ -322,7 +320,7 @@ class UploadCategoriesFragment : UploadBaseFragment(), CategoriesContract.View {
mediaDetailFragment.onResume()
goBackToPreviousScreen()
} else {
callback!!.onPreviousButtonClicked(callback!!.getIndexInViewFlipper(this))
callback?.let { it.onPreviousButtonClicked(it.getIndexInViewFlipper(this)) }
}
}

View file

@ -96,11 +96,10 @@ class DepictsFragment : UploadBaseFragment(), DepictsContract.View {
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)
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)
@ -285,6 +284,7 @@ class DepictsFragment : UploadBaseFragment(), DepictsContract.View {
override fun showProgressDialog() {
progressDialog = ProgressDialog(requireContext())
progressDialog!!.setMessage(getString(R.string.please_wait))
progressDialog!!.setCancelable(false)
progressDialog!!.show()
}

View file

@ -45,8 +45,7 @@ class MediaLicenseFragment : UploadBaseFragment(), MediaLicenseContract.View {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvTitle.text = getString(
R.string.step_count,
binding.tvTitle.text = getString(R.string.step_count,
callback!!.getIndexInViewFlipper(this) + 1,
callback!!.totalNumberOfSteps,
getString(R.string.license_step_title)

View file

@ -1,922 +0,0 @@
package fr.free.nrw.commons.upload.mediaDetails;
import static android.app.Activity.RESULT_OK;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.speech.RecognizerIntent;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import androidx.recyclerview.widget.LinearLayoutManager;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.locationpicker.LocationPicker;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.databinding.FragmentUploadMediaDetailFragmentBinding;
import fr.free.nrw.commons.edit.EditActivity;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.ImageCoordinates;
import fr.free.nrw.commons.upload.SimilarImageDialogFragment;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadBaseFragment;
import fr.free.nrw.commons.upload.UploadItem;
import fr.free.nrw.commons.upload.UploadMediaDetail;
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter;
import fr.free.nrw.commons.utils.ActivityUtils;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.ImageUtils;
import fr.free.nrw.commons.utils.NetworkUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class UploadMediaDetailFragment extends UploadBaseFragment implements
UploadMediaDetailsContract.View, UploadMediaDetailAdapter.EventListener {
private UploadMediaDetailAdapter uploadMediaDetailAdapter;
private final ActivityResultLauncher<Intent> startForResult = registerForActivityResult(
new StartActivityForResult(), result -> {
onCameraPosition(result);
});
private final ActivityResultLauncher<Intent> startForEditActivityResult = registerForActivityResult(
new StartActivityForResult(), result -> {
onEditActivityResult(result);
}
);
private final ActivityResultLauncher<Intent> voiceInputResultLauncher = registerForActivityResult(
new StartActivityForResult(), result -> {
onVoiceInput(result);
}
);
public static Activity activity ;
private int indexOfFragment;
/**
* A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex.
* 12.3433,54.78897 from applicationKvStore.
*/
public static final String LAST_LOCATION = "last_location_while_uploading";
public static final String LAST_ZOOM = "last_zoom_level_while_uploading";
public static final String UPLOADABLE_FILE = "uploadable_file";
public static final String UPLOAD_MEDIA_DETAILS = "upload_media_detail_adapter";
/**
* True when user removes location from the current image
*/
private boolean hasUserRemovedLocation;
@Inject
UploadMediaDetailsContract.UserActionListener presenter;
@Inject
@Named("default_preferences")
JsonKvStore defaultKvStore;
@Inject
RecentLanguagesDao recentLanguagesDao;
private UploadableFile uploadableFile;
private Place place;
private boolean isExpanded = true;
/**
* True if location is added via the "missing location" popup dialog (which appears after
* tapping "Next" if the picture has no geographical coordinates).
*/
private boolean isMissingLocationDialog;
/**
* showNearbyFound will be true, if any nearby location found that needs pictures and the nearby
* popup is yet to be shown Used to show and check if the nearby found popup is already shown
*/
private boolean showNearbyFound;
/**
* nearbyPlace holds the detail of nearby place that need pictures, if any found
*/
private Place nearbyPlace;
private UploadItem uploadItem;
/**
* inAppPictureLocation: use location recorded while using the in-app camera if device camera
* does not record it in the EXIF
*/
private LatLng inAppPictureLocation;
/**
* editableUploadItem : Storing the upload item before going to update the coordinates
*/
private UploadItem editableUploadItem;
private BasicKvStore basicKvStore;
private final String keyForShowingAlertDialog = "isNoNetworkAlertDialogShowing";
private UploadMediaDetailFragmentCallback callback;
private FragmentUploadMediaDetailFragmentBinding binding;
public void setCallback(UploadMediaDetailFragmentCallback callback) {
this.callback = callback;
UploadMediaPresenter.presenterCallback = callback;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if(savedInstanceState!=null && uploadableFile==null) {
uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE);
}
}
public void setImageToBeUploaded(UploadableFile uploadableFile, Place place,
LatLng inAppPictureLocation) {
this.uploadableFile = uploadableFile;
this.place = place;
this.inAppPictureLocation = inAppPictureLocation;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = FragmentUploadMediaDetailFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
activity = getActivity();
basicKvStore = new BasicKvStore(activity, "CurrentUploadImageQualities");
if (callback != null) {
indexOfFragment = callback.getIndexInViewFlipper(this);
init();
}
if(savedInstanceState!=null){
if(uploadMediaDetailAdapter.getItems().size()==0 && callback != null){
uploadMediaDetailAdapter.setItems(savedInstanceState.getParcelableArrayList(UPLOAD_MEDIA_DETAILS));
presenter.setUploadMediaDetails(uploadMediaDetailAdapter.getItems(),
indexOfFragment);
}
}
try {
if(!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, getActivity())) {
ActivityUtils.startActivityWithFlags(
getActivity(), MainActivity.class, Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
} catch (Exception e) {
}
}
private void init() {
if (binding == null) {
return;
}
binding.tvTitle.setText(getString(R.string.step_count, (indexOfFragment + 1),
callback.getTotalNumberOfSteps(), getString(R.string.media_detail_step_title)));
binding.tooltip.setOnClickListener(
v -> showInfoAlert(R.string.media_detail_step_title, R.string.media_details_tooltip));
initPresenter();
presenter.receiveImage(uploadableFile, place, inAppPictureLocation);
initRecyclerView();
if (indexOfFragment == 0) {
binding.btnPrevious.setEnabled(false);
binding.btnPrevious.setAlpha(0.5f);
} else {
binding.btnPrevious.setEnabled(true);
binding.btnPrevious.setAlpha(1.0f);
}
// If the image EXIF data contains the location, show the map icon with a green tick
if (inAppPictureLocation != null ||
(uploadableFile != null && uploadableFile.hasLocation())) {
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
} else {
// Otherwise, show the map icon with a red question mark
Drawable mapQuestionMark =
getResources().getDrawable(R.drawable.ic_map_not_available_20dp);
binding.locationImageView.setImageDrawable(mapQuestionMark);
binding.locationTextView.setText(R.string.add_location);
}
//If this is the last media, we have nothing to copy, lets not show the button
if (indexOfFragment == callback.getTotalNumberOfSteps() - 4) {
binding.btnCopySubsequentMedia.setVisibility(View.GONE);
} else {
binding.btnCopySubsequentMedia.setVisibility(View.VISIBLE);
}
binding.btnNext.setOnClickListener(v -> onNextButtonClicked());
binding.btnPrevious.setOnClickListener(v -> onPreviousButtonClicked());
binding.llEditImage.setOnClickListener(v -> onEditButtonClicked());
binding.llContainerTitle.setOnClickListener(v -> onLlContainerTitleClicked());
binding.llLocationStatus.setOnClickListener(v -> onIbMapClicked());
binding.btnCopySubsequentMedia.setOnClickListener(v -> onButtonCopyTitleDescToSubsequentMedia());
attachImageViewScaleChangeListener();
}
/**
* Attaches the scale change listener to the image view
*/
private void attachImageViewScaleChangeListener() {
binding.backgroundImage.setOnScaleChangeListener(
(scaleFactor, focusX, focusY) -> {
//Whenever the uses plays with the image, lets collapse the media detail container
//only if it is not already collapsed, which resolves flickering of arrow
if (isExpanded) {
expandCollapseLlMediaDetail(false);
}
});
}
/**
* attach the presenter with the view
*/
private void initPresenter() {
presenter.onAttachView(this);
}
/**
* init the description recycler veiw and caption recyclerview
*/
private void initRecyclerView() {
uploadMediaDetailAdapter = new UploadMediaDetailAdapter(this,
defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, ""), recentLanguagesDao, voiceInputResultLauncher);
uploadMediaDetailAdapter.setCallback(this::showInfoAlert);
uploadMediaDetailAdapter.setEventListener(this);
binding.rvDescriptions.setLayoutManager(new LinearLayoutManager(getContext()));
binding.rvDescriptions.setAdapter(uploadMediaDetailAdapter);
}
/**
* show dialog with info
* @param titleStringID
* @param messageStringId
*/
private void showInfoAlert(int titleStringID, int messageStringId) {
DialogUtil.showAlertDialog(getActivity(), getString(titleStringID),
getString(messageStringId), getString(android.R.string.ok), null);
}
public void onNextButtonClicked() {
if (callback == null) {
return;
}
presenter.displayLocDialog(indexOfFragment, inAppPictureLocation, hasUserRemovedLocation);
}
public void onPreviousButtonClicked() {
if (callback == null) {
return;
}
callback.onPreviousButtonClicked(indexOfFragment);
}
public void onEditButtonClicked() {
presenter.onEditButtonClicked(indexOfFragment);
}
@Override
public void showSimilarImageFragment(String originalFilePath, String possibleFilePath,
ImageCoordinates similarImageCoordinates) {
BasicKvStore basicKvStore = new BasicKvStore(getActivity(), "IsAnyImageCancelled");
if (!basicKvStore.getBoolean("IsAnyImageCancelled", false)) {
SimilarImageDialogFragment newFragment = new SimilarImageDialogFragment();
newFragment.setCancelable(false);
newFragment.setCallback(new SimilarImageDialogFragment.Callback() {
@Override
public void onPositiveResponse() {
Timber.d("positive response from similar image fragment");
presenter.useSimilarPictureCoordinates(similarImageCoordinates,
indexOfFragment);
// set the description text when user selects to use coordinate from the other image
// which was taken within 120s
// fixing: https://github.com/commons-app/apps-android-commons/issues/4700
uploadMediaDetailAdapter.getItems().get(0).setDescriptionText(
getString(R.string.similar_coordinate_description_auto_set));
updateMediaDetails(uploadMediaDetailAdapter.getItems());
// Replace the 'Add location' button with 'Edit location' button when user clicks
// yes in similar image dialog
// fixing: https://github.com/commons-app/apps-android-commons/issues/5669
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
}
@Override
public void onNegativeResponse() {
Timber.d("negative response from similar image fragment");
}
});
Bundle args = new Bundle();
args.putString("originalImagePath", originalFilePath);
args.putString("possibleImagePath", possibleFilePath);
newFragment.setArguments(args);
newFragment.show(getChildFragmentManager(), "dialog");
}
}
@Override
public void onImageProcessed(UploadItem uploadItem, Place place) {
if (binding == null) {
return;
}
binding.backgroundImage.setImageURI(uploadItem.getMediaUri());
}
/**
* Sets variables to Show popup if any nearby location needing pictures matches uploadable picture's GPS location
* @param uploadItem
* @param place
*/
@Override
public void onNearbyPlaceFound(UploadItem uploadItem, Place place) {
nearbyPlace = place;
this.uploadItem = uploadItem;
showNearbyFound = true;
if (callback == null) {
return;
}
if (indexOfFragment == 0) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) {
if (callback != null) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
}
}
} else {
showNearbyPlaceFound(nearbyPlace);
}
showNearbyFound = false;
}
}
/**
* Shows nearby place found popup
* @param place
*/
@SuppressLint("StringFormatInvalid")
// To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format
private void showNearbyPlaceFound(Place place) {
final View customLayout = getLayoutInflater().inflate(R.layout.custom_nearby_found, null);
ImageView nearbyFoundImage = customLayout.findViewById(R.id.nearbyItemImage);
nearbyFoundImage.setImageURI(uploadItem.getMediaUri());
final Activity activity = getActivity();
if (activity instanceof UploadActivity) {
final boolean isMultipleFilesSelected = ((UploadActivity) activity).getIsMultipleFilesSelected();
// Determine the message based on the selection status
String message;
if (isMultipleFilesSelected) {
// Use plural message if multiple files are selected
message = String.format(Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_plural),
place.getName());
} else {
// Use singular message if only one file is selected
message = String.format(Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_singular),
place.getName());
}
// Show the AlertDialog with the determined message
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_nearby_place_found_title),
message,
() -> {
// Execute when user confirms the upload is of the specified place
UploadActivity.nearbyPopupAnswers.put(place, true);
presenter.onUserConfirmedUploadIsOfPlace(place, indexOfFragment);
},
() -> {
// Execute when user cancels the upload of the specified place
UploadActivity.nearbyPopupAnswers.put(place, false);
},
customLayout
);
}
}
@Override
public void showProgress(boolean shouldShow) {
if (callback == null) {
return;
}
callback.showProgress(shouldShow);
}
@Override
public void onImageValidationSuccess() {
if (callback == null) {
return;
}
callback.onNextButtonClicked(indexOfFragment);
}
/**
* This method gets called whenever the next/previous button is pressed
*/
@Override
protected void onBecameVisible() {
super.onBecameVisible();
if (callback == null) {
return;
}
presenter.fetchTitleAndDescription(indexOfFragment);
if (showNearbyFound) {
if (UploadActivity.nearbyPopupAnswers.containsKey(nearbyPlace)) {
final boolean response = UploadActivity.nearbyPopupAnswers.get(nearbyPlace);
if (response) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment);
}
} else {
showNearbyPlaceFound(nearbyPlace);
}
showNearbyFound = false;
}
}
@Override
public void showMessage(int stringResourceId, int colorResourceId) {
ViewUtil.showLongToast(getContext(), stringResourceId);
}
@Override
public void showMessage(String message, int colorResourceId) {
ViewUtil.showLongToast(getContext(), message);
}
@Override
public void showDuplicatePicturePopup(UploadItem uploadItem) {
if (defaultKvStore.getBoolean("showDuplicatePicturePopup", true)) {
String uploadTitleFormat = getString(R.string.upload_title_duplicate);
View checkBoxView = View
.inflate(getActivity(), R.layout.nearby_permission_dialog, null);
CheckBox checkBox = (CheckBox) checkBoxView.findViewById(R.id.never_ask_again);
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
defaultKvStore.putBoolean("showDuplicatePicturePopup", false);
}
});
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.duplicate_file_name),
String.format(Locale.getDefault(),
uploadTitleFormat,
uploadItem.getFilename()),
getString(R.string.upload),
getString(R.string.cancel),
() -> {
uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP);
onImageValidationSuccess();
}, null,
checkBoxView);
} else {
uploadItem.setImageQuality(ImageUtils.IMAGE_KEEP);
onImageValidationSuccess();
}
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Does nothing if there is network connectivity and then the user presses okay
*/
@Override
public void showConnectionErrorPopupForCaptionCheck() {
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
() -> {
if (!NetworkUtils.isInternetConnectionEstablished(activity)) {
showConnectionErrorPopupForCaptionCheck();
}
},
() -> {
activity.finish();
});
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Recalls UploadMediaPresenter.getImageQuality for all the next upload items,
* if there is network connectivity and then the user presses okay
*/
@Override
public void showConnectionErrorPopup() {
try {
boolean FLAG_ALERT_DIALOG_SHOWING = basicKvStore.getBoolean(
keyForShowingAlertDialog, false);
if (!FLAG_ALERT_DIALOG_SHOWING) {
basicKvStore.putBoolean(keyForShowingAlertDialog, true);
DialogUtil.showAlertDialog(getActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
() -> {
basicKvStore.putBoolean(keyForShowingAlertDialog, false);
if (NetworkUtils.isInternetConnectionEstablished(activity)) {
int sizeOfUploads = basicKvStore.getInt(
UploadActivity.keyForCurrentUploadImagesSize);
for (int i = indexOfFragment; i < sizeOfUploads; i++) {
presenter.getImageQuality(i, inAppPictureLocation, activity);
}
} else {
showConnectionErrorPopup();
}
},
() -> {
basicKvStore.putBoolean(keyForShowingAlertDialog, false);
activity.finish();
},
null
);
}
} catch (Exception e) {
}
}
@Override
public void showExternalMap(final UploadItem uploadItem) {
goToLocationPickerActivity(uploadItem);
}
/**
* Launches the image editing activity to edit the specified UploadItem.
*
* @param uploadItem The UploadItem to be edited.
*
* This method is called to start the image editing activity for a specific UploadItem.
* It sets the UploadItem as the currently editable item, creates an intent to launch the
* EditActivity, and passes the image file path as an extra in the intent. The activity
* is started using resultLauncher that handles the result in respective callback.
*/
@Override
public void showEditActivity(UploadItem uploadItem) {
editableUploadItem = uploadItem;
Intent intent = new Intent(getContext(), EditActivity.class);
intent.putExtra("image", uploadableFile.getFilePath().toString());
startForEditActivityResult.launch(intent);
}
/**
* Start Location picker activity. Show the location first then user can modify it by clicking
* modify location button.
* @param uploadItem current upload item
*/
private void goToLocationPickerActivity(final UploadItem uploadItem) {
editableUploadItem = uploadItem;
double defaultLatitude = 37.773972;
double defaultLongitude = -122.431297;
double defaultZoom = 16.0;
final Intent locationPickerIntent;
/* Retrieve image location from EXIF if present or
check if user has provided location while using the in-app camera.
Use location of last UploadItem if none of them is available */
if (uploadItem.getGpsCoords() != null && uploadItem.getGpsCoords()
.getDecLatitude() != 0.0 && uploadItem.getGpsCoords().getDecLongitude() != 0.0) {
defaultLatitude = uploadItem.getGpsCoords()
.getDecLatitude();
defaultLongitude = uploadItem.getGpsCoords().getDecLongitude();
defaultZoom = uploadItem.getGpsCoords().getZoomLevel();
locationPickerIntent = new LocationPicker.IntentBuilder()
.defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom))
.activityKey("UploadActivity")
.build(getActivity());
} else {
if (defaultKvStore.getString(LAST_LOCATION) != null) {
final String[] locationLatLng
= defaultKvStore.getString(LAST_LOCATION).split(",");
defaultLatitude = Double.parseDouble(locationLatLng[0]);
defaultLongitude = Double.parseDouble(locationLatLng[1]);
}
if (defaultKvStore.getString(LAST_ZOOM) != null) {
defaultZoom = Double.parseDouble(defaultKvStore.getString(LAST_ZOOM));
}
locationPickerIntent = new LocationPicker.IntentBuilder()
.defaultLocation(new CameraPosition(defaultLatitude,defaultLongitude,defaultZoom))
.activityKey("NoLocationUploadActivity")
.build(getActivity());
}
startForResult.launch(locationPickerIntent);
}
private void onCameraPosition(ActivityResult result){
if (result.getResultCode() == RESULT_OK) {
assert result.getData() != null;
final CameraPosition cameraPosition = LocationPicker.getCameraPosition(result.getData());
if (cameraPosition != null) {
final String latitude = String.valueOf(cameraPosition.getLatitude());
final String longitude = String.valueOf(cameraPosition.getLongitude());
final double zoom = cameraPosition.getZoom();
editLocation(latitude, longitude, zoom);
// If isMissingLocationDialog is true, it means that the user has already tapped the
// "Next" button, so go directly to the next step.
if (isMissingLocationDialog) {
isMissingLocationDialog = false;
onNextButtonClicked();
}
} else {
// If camera position is null means location is removed by the user
removeLocation();
}
}
}
private void onVoiceInput(ActivityResult result) {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
ArrayList<String> resultData = result.getData().getStringArrayListExtra(
RecognizerIntent.EXTRA_RESULTS);
uploadMediaDetailAdapter.handleSpeechResult(resultData.get(0));
}else {
Timber.e("Error %s", result.getResultCode());
}
}
private void onEditActivityResult(ActivityResult result){
if (result.getResultCode() == RESULT_OK) {
String path = result.getData().getStringExtra("editedImageFilePath");
if (Objects.equals(result, "Error")) {
Timber.e("Error in rotating image");
return;
}
try {
if (binding != null){
binding.backgroundImage.setImageURI(Uri.fromFile(new File(path)));
}
editableUploadItem.setContentAndMediaUri(Uri.fromFile(new File(path)));
callback.changeThumbnail(indexOfFragment,
path);
} catch (Exception e) {
Timber.e(e);
}
}
}
/**
* Removes the location data from the image, by setting them to null
*/
public void removeLocation() {
editableUploadItem.getGpsCoords().setDecimalCoords(null);
try {
ExifInterface sourceExif = new ExifInterface(uploadableFile.getFilePath());
String[] exifTags = {
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
};
for (String tag : exifTags) {
sourceExif.setAttribute(tag, null);
}
sourceExif.saveAttributes();
Drawable mapQuestion = getResources().getDrawable(R.drawable.ic_map_not_available_20dp);
if (binding != null) {
binding.locationImageView.setImageDrawable(mapQuestion);
binding.locationTextView.setText(R.string.add_location);
}
editableUploadItem.getGpsCoords().setDecLatitude(0.0);
editableUploadItem.getGpsCoords().setDecLongitude(0.0);
editableUploadItem.getGpsCoords().setImageCoordsExists(false);
hasUserRemovedLocation = true;
Toast.makeText(getContext(), getString(R.string.location_removed), Toast.LENGTH_LONG)
.show();
} catch (Exception e) {
Timber.d(e);
Toast.makeText(getContext(), "Location could not be removed due to internal error",
Toast.LENGTH_LONG).show();
}
}
/**
* Update the old coordinates with new one
* @param latitude new latitude
* @param longitude new longitude
*/
public void editLocation(final String latitude, final String longitude, final double zoom) {
editableUploadItem.getGpsCoords().setDecLatitude(Double.parseDouble(latitude));
editableUploadItem.getGpsCoords().setDecLongitude(Double.parseDouble(longitude));
editableUploadItem.getGpsCoords().setDecimalCoords(latitude + "|" + longitude);
editableUploadItem.getGpsCoords().setImageCoordsExists(true);
editableUploadItem.getGpsCoords().setZoomLevel(zoom);
// Replace the map icon using the one with a green tick
Drawable mapTick = getResources().getDrawable(R.drawable.ic_map_available_20dp);
if (binding != null) {
binding.locationImageView.setImageDrawable(mapTick);
binding.locationTextView.setText(R.string.edit_location);
}
Toast.makeText(getContext(), getString(R.string.location_updated), Toast.LENGTH_LONG).show();
}
@Override
public void updateMediaDetails(List<UploadMediaDetail> uploadMediaDetails) {
uploadMediaDetailAdapter.setItems(uploadMediaDetails);
showNearbyFound =
showNearbyFound && (
uploadMediaDetails == null || uploadMediaDetails.isEmpty()
|| listContainsEmptyDetails(
uploadMediaDetails));
}
/**
* if the media details that come in here are empty
* (empty caption AND empty description, with caption being the decider here)
* this method allows usage of nearby place caption and description if any
* else it takes the media details saved in prior for this picture
* @param uploadMediaDetails saved media details,
* ex: in case when "copy to subsequent media" button is clicked
* for a previous image
* @return boolean whether the details are empty or not
*/
private boolean listContainsEmptyDetails(List<UploadMediaDetail> uploadMediaDetails) {
for (UploadMediaDetail uploadDetail: uploadMediaDetails) {
if (!TextUtils.isEmpty(uploadDetail.getCaptionText()) && !TextUtils.isEmpty(uploadDetail.getDescriptionText())) {
return false;
}
}
return true;
}
/**
* Showing dialog for adding location
*
* @param onSkipClicked proceed for verifying image quality
*/
@Override
public void displayAddLocationDialog(final Runnable onSkipClicked) {
isMissingLocationDialog = true;
DialogUtil.showAlertDialog(requireActivity(),
getString(R.string.no_location_found_title),
getString(R.string.no_location_found_message),
getString(R.string.add_location),
getString(R.string.skip_login),
this::onIbMapClicked,
onSkipClicked);
}
@Override
public void onDestroyView() {
super.onDestroyView();
presenter.onDetachView();
}
public void onLlContainerTitleClicked() {
expandCollapseLlMediaDetail(!isExpanded);
}
/**
* show hide media detail based on
* @param shouldExpand
*/
private void expandCollapseLlMediaDetail(boolean shouldExpand){
if (binding == null) {
return;
}
binding.llContainerMediaDetail.setVisibility(shouldExpand ? View.VISIBLE : View.GONE);
isExpanded = !isExpanded;
binding.ibExpandCollapse.setRotation(binding.ibExpandCollapse.getRotation() + 180);
}
public void onIbMapClicked() {
if (callback == null) {
return;
}
presenter.onMapIconClicked(indexOfFragment);
}
@Override
public void onPrimaryCaptionTextChange(boolean isNotEmpty) {
if (binding == null) {
return;
}
binding.btnCopySubsequentMedia.setEnabled(isNotEmpty);
binding.btnCopySubsequentMedia.setClickable(isNotEmpty);
binding.btnCopySubsequentMedia.setAlpha(isNotEmpty ? 1.0f : 0.5f);
binding.btnNext.setEnabled(isNotEmpty);
binding.btnNext.setClickable(isNotEmpty);
binding.btnNext.setAlpha(isNotEmpty ? 1.0f : 0.5f);
}
/**
* Adds new language item to RecyclerView
*/
@Override
public void addLanguage() {
UploadMediaDetail uploadMediaDetail = new UploadMediaDetail();
uploadMediaDetail.setManuallyAdded(true);//This was manually added by the user
uploadMediaDetailAdapter.addDescription(uploadMediaDetail);
binding.rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.getItemCount()-1);
}
public interface UploadMediaDetailFragmentCallback extends Callback {
void deletePictureAtIndex(int index);
void changeThumbnail(int index, String uri);
}
public void onButtonCopyTitleDescToSubsequentMedia(){
presenter.copyTitleAndDescriptionToSubsequentMedia(indexOfFragment);
Toast.makeText(getContext(), getResources().getString(R.string.copied_successfully), Toast.LENGTH_SHORT).show();
}
@Override
public void onSaveInstanceState(final Bundle outState) {
super.onSaveInstanceState(outState);
if(uploadableFile!=null){
outState.putParcelable(UPLOADABLE_FILE,uploadableFile);
}
if(uploadMediaDetailAdapter!=null){
outState.putParcelableArrayList(UPLOAD_MEDIA_DETAILS,
(ArrayList<? extends Parcelable>) uploadMediaDetailAdapter.getItems());
}
}
@Override
public void onDestroy() {
super.onDestroy();
binding = null;
}
}

View file

@ -0,0 +1,903 @@
package fr.free.nrw.commons.upload.mediaDetails
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.speech.RecognizerIntent
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.CompoundButton
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.exifinterface.media.ExifInterface
import androidx.recyclerview.widget.LinearLayoutManager
import fr.free.nrw.commons.CameraPosition
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.databinding.FragmentUploadMediaDetailFragmentBinding
import fr.free.nrw.commons.edit.EditActivity
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.locationpicker.LocationPicker
import fr.free.nrw.commons.locationpicker.LocationPicker.getCameraPosition
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.SimilarImageDialogFragment
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadBaseFragment
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadMediaDetail
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaPresenter.Companion.presenterCallback
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.ImageUtils
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK
import fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult
import fr.free.nrw.commons.utils.NetworkUtils.isInternetConnectionEstablished
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import timber.log.Timber
import java.io.File
import java.util.ArrayList
import java.util.Locale
import java.util.Objects
import javax.inject.Inject
import javax.inject.Named
class UploadMediaDetailFragment : UploadBaseFragment(), UploadMediaDetailsContract.View,
UploadMediaDetailAdapter.EventListener {
private val startForResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onCameraPosition)
private val startForEditActivityResult = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onEditActivityResult)
private val voiceInputResultLauncher = registerForActivityResult<Intent, ActivityResult>(
ActivityResultContracts.StartActivityForResult(), ::onVoiceInput)
@Inject
lateinit var presenter: UploadMediaDetailsContract.UserActionListener
@Inject
@field:Named("default_preferences")
lateinit var defaultKvStore: JsonKvStore
@Inject
lateinit var recentLanguagesDao: RecentLanguagesDao
/**
* True when user removes location from the current image
*/
var hasUserRemovedLocation = false
/**
* True if location is added via the "missing location" popup dialog (which appears after
* tapping "Next" if the picture has no geographical coordinates).
*/
private var isMissingLocationDialog = false
/**
* showNearbyFound will be true, if any nearby location found that needs pictures and the nearby
* popup is yet to be shown Used to show and check if the nearby found popup is already shown
*/
private var showNearbyFound = false
/**
* nearbyPlace holds the detail of nearby place that need pictures, if any found
*/
private var nearbyPlace: Place? = null
private var uploadItem: UploadItem? = null
/**
* inAppPictureLocation: use location recorded while using the in-app camera if device camera
* does not record it in the EXIF
*/
var inAppPictureLocation: LatLng? = null
/**
* editableUploadItem : Storing the upload item before going to update the coordinates
*/
private var editableUploadItem: UploadItem? = null
private var _binding: FragmentUploadMediaDetailFragmentBinding? = null
private val binding: FragmentUploadMediaDetailFragmentBinding get() = _binding!!
private var basicKvStore: BasicKvStore? = null
private val keyForShowingAlertDialog = "isNoNetworkAlertDialogShowing"
private var uploadableFile: UploadableFile? = null
private var place: Place? = null
private lateinit var uploadMediaDetailAdapter: UploadMediaDetailAdapter
var indexOfFragment = 0
var isExpanded = true
var fragmentCallback: UploadMediaDetailFragmentCallback? = null
set(value) {
field = value
UploadMediaPresenter.presenterCallback = value
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null && uploadableFile == null) {
uploadableFile = savedInstanceState.getParcelable(UPLOADABLE_FILE)
}
}
fun setImageToBeUploaded(
uploadableFile: UploadableFile?, place: Place?, inAppPictureLocation: LatLng?
) {
this.uploadableFile = uploadableFile
this.place = place
this.inAppPictureLocation = inAppPictureLocation
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentUploadMediaDetailFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
basicKvStore = BasicKvStore(requireActivity(), "CurrentUploadImageQualities")
if (fragmentCallback != null) {
indexOfFragment = fragmentCallback!!.getIndexInViewFlipper(this)
initializeFragment()
}
if (savedInstanceState != null) {
if (uploadMediaDetailAdapter.items.isEmpty() && fragmentCallback != null) {
uploadMediaDetailAdapter.items = savedInstanceState.getParcelableArrayList(UPLOAD_MEDIA_DETAILS)!!
presenter.setUploadMediaDetails(uploadMediaDetailAdapter.items, indexOfFragment)
}
}
try {
if (!presenter.getImageQuality(indexOfFragment, inAppPictureLocation, requireActivity())) {
startActivityWithFlags(
requireActivity(),
MainActivity::class.java,
Intent.FLAG_ACTIVITY_CLEAR_TOP,
Intent.FLAG_ACTIVITY_SINGLE_TOP
)
}
} catch (_: Exception) {
}
}
private fun initializeFragment() {
if (_binding == null) {
return
}
binding.tvTitle.text = getString(
R.string.step_count, (indexOfFragment + 1),
fragmentCallback!!.totalNumberOfSteps, getString(R.string.media_detail_step_title)
)
binding.tooltip.setOnClickListener {
showInfoAlert(
R.string.media_detail_step_title,
R.string.media_details_tooltip
)
}
presenter.onAttachView(this)
presenter.setupBasicKvStoreFactory { BasicKvStore(requireActivity(), it) }
presenter.receiveImage(uploadableFile, place, inAppPictureLocation)
initRecyclerView()
with (binding){
if (indexOfFragment == 0) {
btnPrevious.isEnabled = false
btnPrevious.alpha = 0.5f
} else {
btnPrevious.isEnabled = true
btnPrevious.alpha = 1.0f
}
// If the image EXIF data contains the location, show the map icon with a green tick
if (inAppPictureLocation != null || (uploadableFile != null && uploadableFile!!.hasLocation())) {
val mapTick =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_available_20dp)
locationImageView.setImageDrawable(mapTick)
locationTextView.setText(R.string.edit_location)
} else {
// Otherwise, show the map icon with a red question mark
val mapQuestionMark = ContextCompat.getDrawable(
requireContext(),
R.drawable.ic_map_not_available_20dp
)
locationImageView.setImageDrawable(mapQuestionMark)
locationTextView.setText(R.string.add_location)
}
//If this is the last media, we have nothing to copy, lets not show the button
btnCopySubsequentMedia.visibility =
if (indexOfFragment == fragmentCallback!!.totalNumberOfSteps - 4) {
View.GONE
} else {
View.VISIBLE
}
btnNext.setOnClickListener { presenter.displayLocDialog(indexOfFragment, inAppPictureLocation, hasUserRemovedLocation) }
btnPrevious.setOnClickListener { fragmentCallback?.onPreviousButtonClicked(indexOfFragment) }
llEditImage.setOnClickListener { presenter.onEditButtonClicked(indexOfFragment) }
llContainerTitle.setOnClickListener { expandCollapseLlMediaDetail(!isExpanded) }
llLocationStatus.setOnClickListener { presenter.onMapIconClicked(indexOfFragment) }
btnCopySubsequentMedia.setOnClickListener { onButtonCopyTitleDescToSubsequentMedia() }
}
attachImageViewScaleChangeListener()
}
/**
* Attaches the scale change listener to the image view
*/
private fun attachImageViewScaleChangeListener() {
binding.backgroundImage.setOnScaleChangeListener { _: Float, _: Float, _: Float ->
//Whenever the uses plays with the image, lets collapse the media detail container
//only if it is not already collapsed, which resolves flickering of arrow
if (isExpanded) {
expandCollapseLlMediaDetail(false)
}
}
}
/**
* init the description recycler veiw and caption recyclerview
*/
private fun initRecyclerView() {
uploadMediaDetailAdapter = UploadMediaDetailAdapter(
this,
defaultKvStore.getString(Prefs.DESCRIPTION_LANGUAGE, "")!!,
recentLanguagesDao, voiceInputResultLauncher
)
uploadMediaDetailAdapter.callback =
UploadMediaDetailAdapter.Callback { titleStringID: Int, messageStringId: Int ->
showInfoAlert(titleStringID, messageStringId)
}
uploadMediaDetailAdapter.eventListener = this
binding.rvDescriptions.layoutManager = LinearLayoutManager(context)
binding.rvDescriptions.adapter = uploadMediaDetailAdapter
}
private fun showInfoAlert(titleStringID: Int, messageStringId: Int) {
showAlertDialog(
requireActivity(),
getString(titleStringID),
getString(messageStringId),
getString(android.R.string.ok),
null
)
}
override fun showSimilarImageFragment(
originalFilePath: String?, possibleFilePath: String?,
similarImageCoordinates: ImageCoordinates?
) {
val basicKvStore = BasicKvStore(requireActivity(), "IsAnyImageCancelled")
if (!basicKvStore.getBoolean("IsAnyImageCancelled", false)) {
val newFragment = SimilarImageDialogFragment()
newFragment.isCancelable = false
newFragment.callback = object : SimilarImageDialogFragment.Callback {
override fun onPositiveResponse() {
Timber.d("positive response from similar image fragment")
presenter.useSimilarPictureCoordinates(
similarImageCoordinates!!,
indexOfFragment
)
// set the description text when user selects to use coordinate from the other image
// which was taken within 120s
// fixing: https://github.com/commons-app/apps-android-commons/issues/4700
uploadMediaDetailAdapter.items[0].descriptionText =
getString(R.string.similar_coordinate_description_auto_set)
updateMediaDetails(uploadMediaDetailAdapter.items)
// Replace the 'Add location' button with 'Edit location' button when user clicks
// yes in similar image dialog
// fixing: https://github.com/commons-app/apps-android-commons/issues/5669
val mapTick = ContextCompat.getDrawable(
requireContext(),
R.drawable.ic_map_available_20dp
)
binding.locationImageView.setImageDrawable(mapTick)
binding.locationTextView.setText(R.string.edit_location)
}
override fun onNegativeResponse() {
Timber.d("negative response from similar image fragment")
}
}
newFragment.arguments = bundleOf(
"originalImagePath" to originalFilePath,
"possibleImagePath" to possibleFilePath
)
newFragment.show(childFragmentManager, "dialog")
}
}
override fun onImageProcessed(uploadItem: UploadItem) {
if (_binding == null) {
return
}
binding.backgroundImage.setImageURI(uploadItem.mediaUri)
}
override fun onNearbyPlaceFound(
uploadItem: UploadItem, place: Place?
) {
nearbyPlace = place
this.uploadItem = uploadItem
showNearbyFound = true
if (fragmentCallback == null) {
return
}
if (indexOfFragment == 0) {
if (UploadActivity.nearbyPopupAnswers!!.containsKey(nearbyPlace!!)) {
val response = UploadActivity.nearbyPopupAnswers!![nearbyPlace!!]!!
if (response) {
if (fragmentCallback != null) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment)
}
}
} else {
showNearbyPlaceFound(nearbyPlace!!)
}
showNearbyFound = false
}
}
@SuppressLint("StringFormatInvalid") // To avoid the unwanted lint warning that string 'upload_nearby_place_found_description' is not of a valid format
private fun showNearbyPlaceFound(place: Place) {
val customLayout = layoutInflater.inflate(R.layout.custom_nearby_found, null)
val nearbyFoundImage = customLayout.findViewById<ImageView>(R.id.nearbyItemImage)
nearbyFoundImage.setImageURI(uploadItem!!.mediaUri)
val activity: Activity? = activity
if (activity is UploadActivity) {
val isMultipleFilesSelected = activity.isMultipleFilesSelected
// Determine the message based on the selection status
val message = if (isMultipleFilesSelected) {
// Use plural message if multiple files are selected
String.format(
Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_plural),
place.getName()
)
} else {
// Use singular message if only one file is selected
String.format(
Locale.getDefault(),
getString(R.string.upload_nearby_place_found_description_singular),
place.getName()
)
}
// Show the AlertDialog with the determined message
showAlertDialog(
requireActivity(),
getString(R.string.upload_nearby_place_found_title),
message,
{
// Execute when user confirms the upload is of the specified place
UploadActivity.nearbyPopupAnswers!![place] = true
presenter.onUserConfirmedUploadIsOfPlace(place, indexOfFragment)
},
{
// Execute when user cancels the upload of the specified place
UploadActivity.nearbyPopupAnswers!![place] = false
},
customLayout
)
}
}
override fun showProgress(shouldShow: Boolean) {
if (fragmentCallback == null) {
return
}
fragmentCallback!!.showProgress(shouldShow)
}
override fun onImageValidationSuccess() {
if (fragmentCallback == null) {
return
}
fragmentCallback!!.onNextButtonClicked(indexOfFragment)
}
/**
* This method gets called whenever the next/previous button is pressed
*/
override fun onBecameVisible() {
super.onBecameVisible()
if (fragmentCallback == null) {
return
}
presenter.fetchTitleAndDescription(indexOfFragment)
if (showNearbyFound) {
if (UploadActivity.nearbyPopupAnswers!!.containsKey(nearbyPlace!!)) {
val response = UploadActivity.nearbyPopupAnswers!![nearbyPlace!!]!!
if (response) {
presenter.onUserConfirmedUploadIsOfPlace(nearbyPlace, indexOfFragment)
}
} else {
showNearbyPlaceFound(nearbyPlace!!)
}
showNearbyFound = false
}
}
override fun showMessage(stringResourceId: Int, colorResourceId: Int) =
showLongToast(requireContext(), stringResourceId)
override fun showMessage(message: String, colorResourceId: Int) =
showLongToast(requireContext(), message)
override fun showDuplicatePicturePopup(uploadItem: UploadItem) {
if (defaultKvStore.getBoolean("showDuplicatePicturePopup", true)) {
val uploadTitleFormat = getString(R.string.upload_title_duplicate)
val checkBoxView = View
.inflate(activity, R.layout.nearby_permission_dialog, null)
val checkBox = checkBoxView.findViewById<View>(R.id.never_ask_again) as CheckBox
checkBox.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
if (isChecked) {
defaultKvStore.putBoolean("showDuplicatePicturePopup", false)
}
}
showAlertDialog(
requireActivity(),
getString(R.string.duplicate_file_name),
String.format(
Locale.getDefault(),
uploadTitleFormat,
uploadItem.filename
),
getString(R.string.upload),
getString(R.string.cancel),
{
uploadItem.imageQuality = ImageUtils.IMAGE_KEEP
onImageValidationSuccess()
}, null,
checkBoxView
)
} else {
uploadItem.imageQuality = ImageUtils.IMAGE_KEEP
onImageValidationSuccess()
}
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Does nothing if there is network connectivity and then the user presses okay
*/
override fun showConnectionErrorPopupForCaptionCheck() {
showAlertDialog(requireActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
{
if (!isInternetConnectionEstablished(requireActivity())) {
showConnectionErrorPopupForCaptionCheck()
}
},
{
requireActivity().finish()
})
}
/**
* Shows a dialog alerting the user that internet connection is required for upload process
* Recalls UploadMediaPresenter.getImageQuality for all the next upload items,
* if there is network connectivity and then the user presses okay
*/
override fun showConnectionErrorPopup() {
try {
val FLAG_ALERT_DIALOG_SHOWING = basicKvStore!!.getBoolean(
keyForShowingAlertDialog, false
)
if (!FLAG_ALERT_DIALOG_SHOWING) {
basicKvStore!!.putBoolean(keyForShowingAlertDialog, true)
showAlertDialog(
requireActivity(),
getString(R.string.upload_connection_error_alert_title),
getString(R.string.upload_connection_error_alert_detail),
getString(R.string.ok),
getString(R.string.cancel_upload),
{
basicKvStore!!.putBoolean(keyForShowingAlertDialog, false)
if (isInternetConnectionEstablished(requireActivity())) {
val sizeOfUploads = basicKvStore!!.getInt(
UploadActivity.keyForCurrentUploadImagesSize
)
for (i in indexOfFragment until sizeOfUploads) {
presenter.getImageQuality(
i,
inAppPictureLocation,
requireActivity()
)
}
} else {
showConnectionErrorPopup()
}
},
{
basicKvStore!!.putBoolean(keyForShowingAlertDialog, false)
requireActivity().finish()
},
null
)
}
} catch (e: Exception) {
Timber.e(e)
}
}
override fun showExternalMap(uploadItem: UploadItem) =
goToLocationPickerActivity(uploadItem)
/**
* Launches the image editing activity to edit the specified UploadItem.
*
* @param uploadItem The UploadItem to be edited.
*
* This method is called to start the image editing activity for a specific UploadItem.
* It sets the UploadItem as the currently editable item, creates an intent to launch the
* EditActivity, and passes the image file path as an extra in the intent. The activity
* is started using resultLauncher that handles the result in respective callback.
*/
override fun showEditActivity(uploadItem: UploadItem) {
editableUploadItem = uploadItem
val intent = Intent(context, EditActivity::class.java)
intent.putExtra("image", uploadableFile!!.getFilePath().toString())
startForEditActivityResult.launch(intent)
}
/**
* Start Location picker activity. Show the location first then user can modify it by clicking
* modify location button.
* @param uploadItem current upload item
*/
private fun goToLocationPickerActivity(uploadItem: UploadItem) {
editableUploadItem = uploadItem
var defaultLatitude = 37.773972
var defaultLongitude = -122.431297
var defaultZoom = 16.0
val locationPickerIntent: Intent
/* Retrieve image location from EXIF if present or
check if user has provided location while using the in-app camera.
Use location of last UploadItem if none of them is available */
if (uploadItem.gpsCoords != null && uploadItem.gpsCoords!!
.decLatitude != 0.0 && uploadItem.gpsCoords!!.decLongitude != 0.0
) {
defaultLatitude = uploadItem.gpsCoords!!
.decLatitude
defaultLongitude = uploadItem.gpsCoords!!.decLongitude
defaultZoom = uploadItem.gpsCoords!!.zoomLevel
locationPickerIntent = LocationPicker.IntentBuilder()
.defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, defaultZoom))
.activityKey("UploadActivity")
.build(requireActivity())
} else {
if (defaultKvStore.getString(LAST_LOCATION) != null) {
val locationLatLng = defaultKvStore.getString(LAST_LOCATION)!!
.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
defaultLatitude = locationLatLng[0].toDouble()
defaultLongitude = locationLatLng[1].toDouble()
}
if (defaultKvStore.getString(LAST_ZOOM) != null) {
defaultZoom = defaultKvStore.getString(LAST_ZOOM)!!
.toDouble()
}
locationPickerIntent = LocationPicker.IntentBuilder()
.defaultLocation(CameraPosition(defaultLatitude, defaultLongitude, defaultZoom))
.activityKey("NoLocationUploadActivity")
.build(requireActivity())
}
startForResult.launch(locationPickerIntent)
}
private fun onCameraPosition(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK) {
checkNotNull(result.data)
val cameraPosition = getCameraPosition(
result.data!!
)
if (cameraPosition != null) {
val latitude = cameraPosition.latitude.toString()
val longitude = cameraPosition.longitude.toString()
val zoom = cameraPosition.zoom
editLocation(latitude, longitude, zoom)
// If isMissingLocationDialog is true, it means that the user has already tapped the
// "Next" button, so go directly to the next step.
if (isMissingLocationDialog) {
isMissingLocationDialog = false
presenter.displayLocDialog(
indexOfFragment,
inAppPictureLocation,
hasUserRemovedLocation
)
}
} else {
// If camera position is null means location is removed by the user
removeLocation()
}
}
}
private fun onVoiceInput(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val resultData = result.data!!.getStringArrayListExtra(
RecognizerIntent.EXTRA_RESULTS
)
uploadMediaDetailAdapter.handleSpeechResult(resultData!![0])
} else {
Timber.e("Error %s", result.resultCode)
}
}
private fun onEditActivityResult(result: ActivityResult) {
if (result.resultCode == Activity.RESULT_OK) {
val path = result.data!!.getStringExtra("editedImageFilePath")
if (Objects.equals(result, "Error")) {
Timber.e("Error in rotating image")
return
}
try {
if (_binding != null) {
binding.backgroundImage.setImageURI(Uri.fromFile(File(path!!)))
}
editableUploadItem!!.setContentAndMediaUri(Uri.fromFile(File(path!!)))
fragmentCallback!!.changeThumbnail(
indexOfFragment,
path
)
} catch (e: Exception) {
Timber.e(e)
}
}
}
/**
* Removes the location data from the image, by setting them to null
*/
private fun removeLocation() {
editableUploadItem!!.gpsCoords!!.decimalCoords = null
try {
val sourceExif = ExifInterface(
uploadableFile!!.getFilePath()
)
val exifTags = arrayOf(
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
)
for (tag in exifTags) {
sourceExif.setAttribute(tag, null)
}
sourceExif.saveAttributes()
val mapQuestion =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_not_available_20dp)
if (_binding != null) {
binding.locationImageView.setImageDrawable(mapQuestion)
binding.locationTextView.setText(R.string.add_location)
}
editableUploadItem!!.gpsCoords!!.decLatitude = 0.0
editableUploadItem!!.gpsCoords!!.decLongitude = 0.0
editableUploadItem!!.gpsCoords!!.imageCoordsExists = false
hasUserRemovedLocation = true
Toast.makeText(context, getString(R.string.location_removed), Toast.LENGTH_LONG)
.show()
} catch (e: Exception) {
Timber.d(e)
Toast.makeText(
context, "Location could not be removed due to internal error",
Toast.LENGTH_LONG
).show()
}
}
/**
* Update the old coordinates with new one
* @param latitude new latitude
* @param longitude new longitude
*/
fun editLocation(latitude: String, longitude: String, zoom: Double) {
editableUploadItem!!.gpsCoords!!.decLatitude = latitude.toDouble()
editableUploadItem!!.gpsCoords!!.decLongitude = longitude.toDouble()
editableUploadItem!!.gpsCoords!!.decimalCoords = "$latitude|$longitude"
editableUploadItem!!.gpsCoords!!.imageCoordsExists = true
editableUploadItem!!.gpsCoords!!.zoomLevel = zoom
// Replace the map icon using the one with a green tick
val mapTick = ContextCompat.getDrawable(requireContext(), R.drawable.ic_map_available_20dp)
if (_binding != null) {
binding.locationImageView.setImageDrawable(mapTick)
binding.locationTextView.setText(R.string.edit_location)
}
Toast.makeText(context, getString(R.string.location_updated), Toast.LENGTH_LONG).show()
}
override fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail>) {
uploadMediaDetailAdapter.items = uploadMediaDetails
showNearbyFound =
showNearbyFound && (uploadMediaDetails.isEmpty() || listContainsEmptyDetails(
uploadMediaDetails
))
}
/**
* if the media details that come in here are empty
* (empty caption AND empty description, with caption being the decider here)
* this method allows usage of nearby place caption and description if any
* else it takes the media details saved in prior for this picture
* @param uploadMediaDetails saved media details,
* ex: in case when "copy to subsequent media" button is clicked
* for a previous image
* @return boolean whether the details are empty or not
*/
private fun listContainsEmptyDetails(uploadMediaDetails: List<UploadMediaDetail>): Boolean {
for ((_, descriptionText, captionText) in uploadMediaDetails) {
if (!TextUtils.isEmpty(captionText) && !TextUtils.isEmpty(
descriptionText
)
) {
return false
}
}
return true
}
/**
* Showing dialog for adding location
*
* @param runnable proceed for verifying image quality
*/
override fun displayAddLocationDialog(runnable: Runnable) {
isMissingLocationDialog = true
showAlertDialog(
requireActivity(),
getString(R.string.no_location_found_title),
getString(R.string.no_location_found_message),
getString(R.string.add_location),
getString(R.string.skip_login),
{
presenter.onMapIconClicked(indexOfFragment)
},
runnable
)
}
override fun showBadImagePopup(errorCode: Int, index: Int, uploadItem: UploadItem) {
//If the error message is null, we will probably not show anything
val activity = requireActivity()
val errorMessageForResult = getErrorMessageForResult(activity, errorCode)
if (errorMessageForResult.isNotEmpty()) {
showAlertDialog(
activity,
activity.getString(R.string.upload_problem_image),
errorMessageForResult,
activity.getString(R.string.upload),
activity.getString(R.string.cancel),
{
showProgress(false)
uploadItem.imageQuality = IMAGE_OK
},
{
presenterCallback!!.deletePictureAtIndex(index)
}
)?.setCancelable(false)
}
}
override fun onDestroyView() {
super.onDestroyView()
presenter.onDetachView()
}
fun expandCollapseLlMediaDetail(shouldExpand: Boolean) {
if (_binding == null) {
return
}
binding.llContainerMediaDetail.visibility =
if (shouldExpand) View.VISIBLE else View.GONE
isExpanded = !isExpanded
binding.ibExpandCollapse.rotation = binding.ibExpandCollapse.rotation + 180
}
override fun onPrimaryCaptionTextChange(isNotEmpty: Boolean) {
if (_binding == null) {
return
}
binding.btnCopySubsequentMedia.isEnabled = isNotEmpty
binding.btnCopySubsequentMedia.isClickable = isNotEmpty
binding.btnCopySubsequentMedia.alpha = if (isNotEmpty) 1.0f else 0.5f
binding.btnNext.isEnabled = isNotEmpty
binding.btnNext.isClickable = isNotEmpty
binding.btnNext.alpha = if (isNotEmpty) 1.0f else 0.5f
}
/**
* Adds new language item to RecyclerView
*/
override fun addLanguage() {
val uploadMediaDetail = UploadMediaDetail()
uploadMediaDetail.isManuallyAdded = true //This was manually added by the user
uploadMediaDetailAdapter.addDescription(uploadMediaDetail)
binding.rvDescriptions.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1)
}
fun onButtonCopyTitleDescToSubsequentMedia() {
presenter.copyTitleAndDescriptionToSubsequentMedia(indexOfFragment)
Toast.makeText(context, R.string.copied_successfully, Toast.LENGTH_SHORT).show()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (uploadableFile != null) {
outState.putParcelable(UPLOADABLE_FILE, uploadableFile)
}
outState.putParcelableArrayList(
UPLOAD_MEDIA_DETAILS,
ArrayList(uploadMediaDetailAdapter.items)
)
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
interface UploadMediaDetailFragmentCallback : Callback {
fun deletePictureAtIndex(index: Int)
fun changeThumbnail(index: Int, uri: String)
}
companion object {
/**
* A key for applicationKvStore. By this key we can retrieve the location of last UploadItem ex.
* 12.3433,54.78897 from applicationKvStore.
*/
const val LAST_LOCATION: String = "last_location_while_uploading"
const val LAST_ZOOM: String = "last_zoom_level_while_uploading"
const val UPLOADABLE_FILE: String = "uploadable_file"
const val UPLOAD_MEDIA_DETAILS: String = "upload_media_detail_adapter"
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.upload.mediaDetails
import android.app.Activity
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.upload.ImageCoordinates
@ -15,9 +16,9 @@ import fr.free.nrw.commons.upload.UploadMediaDetail
*/
interface UploadMediaDetailsContract {
interface View : SimilarImageInterface {
fun onImageProcessed(uploadItem: UploadItem?, place: Place?)
fun onImageProcessed(uploadItem: UploadItem)
fun onNearbyPlaceFound(uploadItem: UploadItem?, place: Place?)
fun onNearbyPlaceFound(uploadItem: UploadItem, place: Place?)
fun showProgress(shouldShow: Boolean)
@ -25,9 +26,9 @@ interface UploadMediaDetailsContract {
fun showMessage(stringResourceId: Int, colorResourceId: Int)
fun showMessage(message: String?, colorResourceId: Int)
fun showMessage(message: String, colorResourceId: Int)
fun showDuplicatePicturePopup(uploadItem: UploadItem?)
fun showDuplicatePicturePopup(uploadItem: UploadItem)
/**
* Shows a dialog alerting the user that internet connection is required for upload process
@ -42,16 +43,20 @@ interface UploadMediaDetailsContract {
*/
fun showConnectionErrorPopupForCaptionCheck()
fun showExternalMap(uploadItem: UploadItem?)
fun showExternalMap(uploadItem: UploadItem)
fun showEditActivity(uploadItem: UploadItem?)
fun showEditActivity(uploadItem: UploadItem)
fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail?>?)
fun updateMediaDetails(uploadMediaDetails: List<UploadMediaDetail>)
fun displayAddLocationDialog(runnable: Runnable?)
fun displayAddLocationDialog(runnable: Runnable)
fun showBadImagePopup(errorCode: Int, index: Int, uploadItem: UploadItem)
}
interface UserActionListener : BasePresenter<View?> {
fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore)
fun receiveImage(
uploadableFile: UploadableFile?,
place: Place?,
@ -59,7 +64,7 @@ interface UploadMediaDetailsContract {
)
fun setUploadMediaDetails(
uploadMediaDetails: List<UploadMediaDetail?>?,
uploadMediaDetails: List<UploadMediaDetail>,
uploadItemIndex: Int
)
@ -74,7 +79,7 @@ interface UploadMediaDetailsContract {
fun getImageQuality(
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
activity: Activity?
activity: Activity
): Boolean
/**
@ -87,7 +92,8 @@ interface UploadMediaDetailsContract {
* @param hasUserRemovedLocation True if user has removed location from the image
*/
fun displayLocDialog(
uploadItemIndex: Int, inAppPictureLocation: LatLng?,
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
hasUserRemovedLocation: Boolean
)
@ -97,7 +103,7 @@ interface UploadMediaDetailsContract {
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
fun checkImageQuality(uploadItem: UploadItem?, index: Int)
fun checkImageQuality(uploadItem: UploadItem, index: Int)
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
@ -111,7 +117,7 @@ interface UploadMediaDetailsContract {
fun fetchTitleAndDescription(indexInViewFlipper: Int)
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates?, uploadItemIndex: Int)
fun useSimilarPictureCoordinates(imageCoordinates: ImageCoordinates, uploadItemIndex: Int)
fun onMapIconClicked(indexInViewFlipper: Int)

View file

@ -1,547 +0,0 @@
package fr.free.nrw.commons.upload.mediaDetails;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.activity;
import static fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION;
import static fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import static fr.free.nrw.commons.utils.ImageUtils.getErrorMessageForResult;
import android.app.Activity;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.filepicker.UploadableFile;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Place;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.ImageCoordinates;
import fr.free.nrw.commons.upload.SimilarImageInterface;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadItem;
import fr.free.nrw.commons.upload.UploadMediaDetail;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.UserActionListener;
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailsContract.View;
import fr.free.nrw.commons.utils.DialogUtil;
import io.github.coordinates2country.Coordinates2Country;
import io.reactivex.Maybe;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import java.lang.reflect.Proxy;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Named;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.json.JSONObject;
import timber.log.Timber;
public class UploadMediaPresenter implements UserActionListener, SimilarImageInterface {
private static final UploadMediaDetailsContract.View DUMMY = (UploadMediaDetailsContract.View) Proxy
.newProxyInstance(
UploadMediaDetailsContract.View.class.getClassLoader(),
new Class[]{UploadMediaDetailsContract.View.class},
(proxy, method, methodArgs) -> null);
private final UploadRepository repository;
private UploadMediaDetailsContract.View view = DUMMY;
private CompositeDisposable compositeDisposable;
private final JsonKvStore defaultKVStore;
private Scheduler ioScheduler;
private Scheduler mainThreadScheduler;
public static UploadMediaDetailFragmentCallback presenterCallback ;
private final List<String> WLM_SUPPORTED_COUNTRIES= Arrays.asList("am","at","az","br","hr","sv","fi","fr","de","gh","in","ie","il","mk","my","mt","pk","pe","pl","ru","rw","si","es","se","tw","ug","ua","us");
private Map<String, String> countryNamesAndCodes = null;
private final String keyForCurrentUploadImageQualities = "UploadedImagesQualities";
/**
* Variable used to determine if the battery-optimisation dialog is being shown or not
*/
public static boolean isBatteryDialogShowing;
public static boolean isCategoriesDialogShowing;
@Inject
public UploadMediaPresenter(final UploadRepository uploadRepository,
@Named("default_preferences") final JsonKvStore defaultKVStore,
@Named(IO_THREAD) final Scheduler ioScheduler,
@Named(MAIN_THREAD) final Scheduler mainThreadScheduler) {
this.repository = uploadRepository;
this.defaultKVStore = defaultKVStore;
this.ioScheduler = ioScheduler;
this.mainThreadScheduler = mainThreadScheduler;
compositeDisposable = new CompositeDisposable();
}
@Override
public void onAttachView(final View view) {
this.view = view;
}
@Override
public void onDetachView() {
this.view = DUMMY;
compositeDisposable.clear();
}
/**
* Sets the Upload Media Details for the corresponding upload item
*/
@Override
public void setUploadMediaDetails(final List<UploadMediaDetail> uploadMediaDetails, final int uploadItemIndex) {
repository.getUploads().get(uploadItemIndex).setUploadMediaDetails(uploadMediaDetails);
}
/**
* Receives the corresponding uploadable file, processes it and return the view with and uplaod item
*/
@Override
public void receiveImage(final UploadableFile uploadableFile, final Place place,
final LatLng inAppPictureLocation) {
view.showProgress(true);
compositeDisposable.add(
repository
.preProcessImage(uploadableFile, place, this, inAppPictureLocation)
.map(uploadItem -> {
if(place!=null && place.isMonument()){
if (place.location != null) {
final String countryCode = reverseGeoCode(place.location);
if (countryCode != null && WLM_SUPPORTED_COUNTRIES
.contains(countryCode.toLowerCase(Locale.ROOT))) {
uploadItem.setWLMUpload(true);
uploadItem.setCountryCode(countryCode.toLowerCase(Locale.ROOT));
}
}
}
return uploadItem;
})
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(uploadItem ->
{
view.onImageProcessed(uploadItem, place);
view.updateMediaDetails(uploadItem.getUploadMediaDetails());
view.showProgress(false);
final ImageCoordinates gpsCoords = uploadItem.getGpsCoords();
final boolean hasImageCoordinates =
gpsCoords != null && gpsCoords.getImageCoordsExists();
if (hasImageCoordinates && place == null) {
checkNearbyPlaces(uploadItem);
}
},
throwable -> Timber.e(throwable, "Error occurred in processing images")));
}
@Nullable
private String reverseGeoCode(final LatLng latLng){
if(countryNamesAndCodes == null){
countryNamesAndCodes = getCountryNamesAndCodes();
}
return countryNamesAndCodes.get(Coordinates2Country.country(latLng.getLatitude(), latLng.getLongitude()));
}
/**
* Creates HashMap containing all ISO countries 2-letter codes provided by <code>Locale.getISOCountries()</code>
* and their english names
*
* @return HashMap where Key is country english name and Value is 2-letter country code
* e.g. ["Germany":"DE", ...]
*/
private Map<String, String> getCountryNamesAndCodes(){
final Map<String, String> result = new HashMap<>();
final String[] isoCountries = Locale.getISOCountries();
for (final String isoCountry : isoCountries) {
result.put(
new Locale("en", isoCountry).getDisplayCountry(Locale.ENGLISH),
isoCountry
);
}
return result;
}
/**
* This method checks for the nearest location that needs images and suggests it to the user.
*/
private void checkNearbyPlaces(final UploadItem uploadItem) {
final Disposable checkNearbyPlaces = Maybe.fromCallable(() -> repository
.checkNearbyPlaces(uploadItem.getGpsCoords().getDecLatitude(),
uploadItem.getGpsCoords().getDecLongitude()))
.subscribeOn(ioScheduler)
.observeOn(mainThreadScheduler)
.subscribe(place -> {
if (place != null) {
view.onNearbyPlaceFound(uploadItem, place);
}
},
throwable -> Timber.e(throwable, "Error occurred in processing images"));
compositeDisposable.add(checkNearbyPlaces);
}
/**
* Checks if the image has a location. Displays a dialog alerting user that no
* location has been to added to the image and asking them to add one, if location was not
* removed by the user
*
* @param uploadItemIndex Index of the uploadItem which has no location
* @param inAppPictureLocation In app picture location (if any)
* @param hasUserRemovedLocation True if user has removed location from the image
*/
@Override
public void displayLocDialog(final int uploadItemIndex, final LatLng inAppPictureLocation,
final boolean hasUserRemovedLocation) {
final List<UploadItem> uploadItems = repository.getUploads();
final UploadItem uploadItem = uploadItems.get(uploadItemIndex);
if (uploadItem.getGpsCoords().getDecimalCoords() == null && inAppPictureLocation == null
&& !hasUserRemovedLocation) {
final Runnable onSkipClicked = () -> {
verifyCaptionQuality(uploadItem);
};
view.displayAddLocationDialog(onSkipClicked);
} else {
verifyCaptionQuality(uploadItem);
}
}
/**
* Verifies the image's caption and calls function to handle the result
*
* @param uploadItem UploadItem whose caption is checked
*/
private void verifyCaptionQuality(final UploadItem uploadItem) {
view.showProgress(true);
compositeDisposable.add(
repository
.getCaptionQuality(uploadItem)
.observeOn(mainThreadScheduler)
.subscribe(capResult -> {
view.showProgress(false);
handleCaptionResult(capResult, uploadItem);
},
throwable -> {
view.showProgress(false);
if (throwable instanceof UnknownHostException) {
view.showConnectionErrorPopupForCaptionCheck();
} else {
view.showMessage("" + throwable.getLocalizedMessage(),
R.color.color_error);
}
Timber.e(throwable, "Error occurred while handling image");
})
);
}
/**
* Handles image's caption results and shows dialog if necessary
*
* @param errorCode Error code of the UploadItem
* @param uploadItem UploadItem whose caption is checked
*/
public void handleCaptionResult(final Integer errorCode, final UploadItem uploadItem) {
// If errorCode is empty caption show message
if (errorCode == EMPTY_CAPTION) {
Timber.d("Captions are empty. Showing toast");
view.showMessage(R.string.add_caption_toast, R.color.color_error);
}
// If image with same file name exists check the bit in errorCode is set or not
if ((errorCode & FILE_NAME_EXISTS) != 0) {
Timber.d("Trying to show duplicate picture popup");
view.showDuplicatePicturePopup(uploadItem);
}
// If caption is not duplicate or user still wants to upload it
if (errorCode == IMAGE_OK) {
Timber.d("Image captions are okay or user still wants to upload it");
view.onImageValidationSuccess();
}
}
/**
* Copies the caption and description of the current item to the subsequent media
*/
@Override
public void copyTitleAndDescriptionToSubsequentMedia(final int indexInViewFlipper) {
for(int i = indexInViewFlipper+1; i < repository.getCount(); i++){
final UploadItem subsequentUploadItem = repository.getUploads().get(i);
subsequentUploadItem.setUploadMediaDetails(deepCopy(repository.getUploads().get(indexInViewFlipper).getUploadMediaDetails()));
}
}
/**
* Fetches and set the caption and description of the item
*/
@Override
public void fetchTitleAndDescription(final int indexInViewFlipper) {
final UploadItem currentUploadItem = repository.getUploads().get(indexInViewFlipper);
view.updateMediaDetails(currentUploadItem.getUploadMediaDetails());
}
@NotNull
private List<UploadMediaDetail> deepCopy(final List<UploadMediaDetail> uploadMediaDetails) {
final ArrayList<UploadMediaDetail> newList = new ArrayList<>();
for (final UploadMediaDetail uploadMediaDetail : uploadMediaDetails) {
newList.add(uploadMediaDetail.javaCopy());
}
return newList;
}
@Override
public void useSimilarPictureCoordinates(final ImageCoordinates imageCoordinates, final int uploadItemIndex) {
repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex);
}
@Override
public void onMapIconClicked(final int indexInViewFlipper) {
view.showExternalMap(repository.getUploads().get(indexInViewFlipper));
}
@Override
public void onEditButtonClicked(final int indexInViewFlipper){
view.showEditActivity(repository.getUploads().get(indexInViewFlipper));
}
/**
* Updates the information regarding the specified place for the specified upload item
* when the user confirms the suggested nearby place.
*
* @param place The place to be associated with the uploads.
* @param uploadItemIndex Index of the uploadItem whose detected place has been confirmed
*/
@Override
public void onUserConfirmedUploadIsOfPlace(final Place place, final int uploadItemIndex) {
final UploadItem uploadItem = repository.getUploads().get(uploadItemIndex);
uploadItem.setPlace(place);
final List<UploadMediaDetail> uploadMediaDetails = uploadItem.getUploadMediaDetails();
// Update UploadMediaDetail object for this UploadItem
uploadMediaDetails.set(0, new UploadMediaDetail(place));
// 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
view.updateMediaDetails(uploadMediaDetails);
UploadActivity.setUploadIsOfAPlace(true);
}
/**
* Calculates the image quality
*
* @param uploadItemIndex Index of the UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @param activity Context reference
* @return true if no internal error occurs, else returns false
*/
@Override
public boolean getImageQuality(final int uploadItemIndex, final LatLng inAppPictureLocation,
final Activity activity) {
final List<UploadItem> uploadItems = repository.getUploads();
view.showProgress(true);
if (uploadItems.isEmpty()) {
view.showProgress(false);
// No internationalization required for this error message because it's an internal error.
view.showMessage(
"Internal error: Zero upload items received by the Upload Media Detail Fragment. Sorry, please upload again.",
R.color.color_error);
return false;
}
final UploadItem uploadItem = uploadItems.get(uploadItemIndex);
compositeDisposable.add(
repository
.getImageQuality(uploadItem, inAppPictureLocation)
.observeOn(mainThreadScheduler)
.subscribe(imageResult -> {
storeImageQuality(imageResult, uploadItemIndex, activity, uploadItem);
},
throwable -> {
if (throwable instanceof UnknownHostException) {
view.showProgress(false);
view.showConnectionErrorPopup();
} else {
view.showMessage("" + throwable.getLocalizedMessage(),
R.color.color_error);
}
Timber.e(throwable, "Error occurred while handling image");
})
);
return true;
}
/**
* Stores the image quality in JSON format in SharedPrefs
*
* @param imageResult Image quality
* @param uploadItemIndex Index of the UploadItem whose quality is calculated
* @param activity Context reference
* @param uploadItem UploadItem whose quality is to be checked
*/
private void storeImageQuality(final Integer imageResult, final int uploadItemIndex, final Activity activity,
final UploadItem uploadItem) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
jsonObject.put("UploadItem" + uploadItemIndex, imageResult);
store.putString(keyForCurrentUploadImageQualities, jsonObject.toString());
} catch (final Exception e) {
Timber.e(e);
}
if (uploadItemIndex == 0) {
if (!isBatteryDialogShowing && !isCategoriesDialogShowing) {
// if battery-optimisation dialog is not being shown, call checkImageQuality
checkImageQuality(uploadItem, uploadItemIndex);
} else {
view.showProgress(false);
}
}
}
/**
* Used to check image quality from stored qualities and display dialogs
*
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
@Override
public void checkImageQuality(final UploadItem uploadItem, final int index) {
if ((uploadItem.getImageQuality() != IMAGE_OK) && (uploadItem.getImageQuality()
!= IMAGE_KEEP)) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
final Integer imageQuality = (int) jsonObject.get("UploadItem" + index);
view.showProgress(false);
if (imageQuality == IMAGE_OK) {
uploadItem.setHasInvalidLocation(false);
uploadItem.setImageQuality(imageQuality);
} else {
handleBadImage(imageQuality, uploadItem, index);
}
} catch (final Exception e) {
}
}
}
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
*
* @param size Size of uploadableFiles
* @param index Index of the UploadItem which was deleted
*/
@Override
public void updateImageQualitiesJSON(final int size, final int index) {
final BasicKvStore store = new BasicKvStore(activity,
UploadActivity.storeNameForCurrentUploadImagesSize);
final String value = store.getString(keyForCurrentUploadImageQualities, null);
final JSONObject jsonObject;
try {
if (value != null) {
jsonObject = new JSONObject(value);
} else {
jsonObject = new JSONObject();
}
for (int i = index; i < (size - 1); i++) {
jsonObject.put("UploadItem" + i, jsonObject.get("UploadItem" + (i + 1)));
}
jsonObject.remove("UploadItem" + (size - 1));
store.putString(keyForCurrentUploadImageQualities, jsonObject.toString());
} catch (final Exception e) {
Timber.e(e);
}
}
/**
* Handles bad pictures, like too dark, already on wikimedia, downloaded from internet
*
* @param errorCode Error code of the bad image quality
* @param uploadItem UploadItem whose quality is bad
* @param index Index of item whose quality is bad
*/
public void handleBadImage(final Integer errorCode,
final UploadItem uploadItem, final int index) {
Timber.d("Handle bad picture with error code %d", errorCode);
if (errorCode >= 8) { // If location of image and nearby does not match
uploadItem.setHasInvalidLocation(true);
}
// If image has some other problems, show popup accordingly
if (errorCode != EMPTY_CAPTION && errorCode != FILE_NAME_EXISTS) {
showBadImagePopup(errorCode, index, activity, uploadItem);
}
}
/**
* Shows a dialog describing the potential problems in the current image
*
* @param errorCode Has the potential problems in the current image
* @param index Index of the UploadItem which has problems
* @param activity Context reference
* @param uploadItem UploadItem which has problems
*/
public void showBadImagePopup(final Integer errorCode,
final int index, final Activity activity, final UploadItem uploadItem) {
final String errorMessageForResult = getErrorMessageForResult(activity, errorCode);
if (!StringUtils.isBlank(errorMessageForResult)) {
DialogUtil.showAlertDialog(activity,
activity.getString(R.string.upload_problem_image),
errorMessageForResult,
activity.getString(R.string.upload),
activity.getString(R.string.cancel),
() -> {
view.showProgress(false);
uploadItem.setImageQuality(IMAGE_OK);
},
() -> {
presenterCallback.deletePictureAtIndex(index);
}
).setCancelable(false);
}
//If the error message is null, we will probably not show anything
}
/**
* notifies the user that a similar image exists
*/
@Override
public void showSimilarImageFragment(final String originalFilePath, final String possibleFilePath,
final ImageCoordinates similarImageCoordinates) {
view.showSimilarImageFragment(originalFilePath, possibleFilePath,
similarImageCoordinates
);
}
}

View file

@ -0,0 +1,441 @@
package fr.free.nrw.commons.upload.mediaDetails
import android.app.Activity
import fr.free.nrw.commons.R
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD
import fr.free.nrw.commons.filepicker.UploadableFile
import fr.free.nrw.commons.kvstore.BasicKvStore
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.SimilarImageInterface
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadActivity.Companion.setUploadIsOfAPlace
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadMediaDetail
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.UploadMediaDetailFragmentCallback
import fr.free.nrw.commons.utils.ImageUtils.EMPTY_CAPTION
import fr.free.nrw.commons.utils.ImageUtils.FILE_NAME_EXISTS
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_KEEP
import fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK
import io.github.coordinates2country.Coordinates2Country
import io.reactivex.Maybe
import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable
import org.json.JSONObject
import timber.log.Timber
import java.lang.reflect.Method
import java.lang.reflect.Proxy
import java.net.UnknownHostException
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
class UploadMediaPresenter @Inject constructor(
private val repository: UploadRepository,
@param:Named(IO_THREAD) private val ioScheduler: Scheduler,
@param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler
) : UploadMediaDetailsContract.UserActionListener, SimilarImageInterface {
private var view = DUMMY
private val compositeDisposable = CompositeDisposable()
private val countryNamesAndCodes: Map<String, String> by lazy {
// Create a map containing all ISO countries 2-letter codes provided by
// `Locale.getISOCountries()` and their english names
buildMap {
Locale.getISOCountries().forEach {
put(Locale("en", it).getDisplayCountry(Locale.ENGLISH), it)
}
}
}
lateinit var basicKvStoreFactory: (String) -> BasicKvStore
override fun onAttachView(view: UploadMediaDetailsContract.View) {
this.view = view
}
override fun onDetachView() {
view = DUMMY
compositeDisposable.clear()
}
/**
* Sets the Upload Media Details for the corresponding upload item
*/
override fun setUploadMediaDetails(
uploadMediaDetails: List<UploadMediaDetail>,
uploadItemIndex: Int
) {
repository.getUploads()[uploadItemIndex].uploadMediaDetails = uploadMediaDetails.toMutableList()
}
override fun setupBasicKvStoreFactory(factory: (String) -> BasicKvStore) {
basicKvStoreFactory = factory
}
/**
* Receives the corresponding uploadable file, processes it and return the view with and uplaod item
*/
override fun receiveImage(
uploadableFile: UploadableFile?,
place: Place?,
inAppPictureLocation: LatLng?
) {
view.showProgress(true)
compositeDisposable.add(
repository.preProcessImage(
uploadableFile, place, this, inAppPictureLocation
).map { uploadItem: UploadItem ->
if (place != null && place.isMonument && place.location != null) {
val countryCode = countryNamesAndCodes[Coordinates2Country.country(
place.location.latitude,
place.location.longitude
)]
if (countryCode != null && WLM_SUPPORTED_COUNTRIES.contains(countryCode.lowercase())) {
uploadItem.isWLMUpload = true
uploadItem.countryCode = countryCode.lowercase()
}
}
uploadItem
}.subscribeOn(ioScheduler).observeOn(mainThreadScheduler)
.subscribe({ uploadItem: UploadItem ->
view.onImageProcessed(uploadItem)
view.updateMediaDetails(uploadItem.uploadMediaDetails)
view.showProgress(false)
val gpsCoords = uploadItem.gpsCoords
val hasImageCoordinates = gpsCoords != null && gpsCoords.imageCoordsExists
if (hasImageCoordinates && place == null) {
checkNearbyPlaces(uploadItem)
}
}, { throwable: Throwable? ->
Timber.e(throwable, "Error occurred in processing images")
})
)
}
/**
* This method checks for the nearest location that needs images and suggests it to the user.
*/
private fun checkNearbyPlaces(uploadItem: UploadItem) {
compositeDisposable.add(Maybe.fromCallable {
repository.checkNearbyPlaces(
uploadItem.gpsCoords!!.decLatitude, uploadItem.gpsCoords!!.decLongitude
)
}.subscribeOn(ioScheduler).observeOn(mainThreadScheduler).subscribe({
view.onNearbyPlaceFound(uploadItem, it)
}, { throwable: Throwable? ->
Timber.e(throwable, "Error occurred in processing images")
})
)
}
/**
* Checks if the image has a location. Displays a dialog alerting user that no
* location has been to added to the image and asking them to add one, if location was not
* removed by the user
*
* @param uploadItemIndex Index of the uploadItem which has no location
* @param inAppPictureLocation In app picture location (if any)
* @param hasUserRemovedLocation True if user has removed location from the image
*/
override fun displayLocDialog(
uploadItemIndex: Int, inAppPictureLocation: LatLng?,
hasUserRemovedLocation: Boolean
) {
val uploadItem = repository.getUploads()[uploadItemIndex]
if (uploadItem.gpsCoords!!.decimalCoords == null && inAppPictureLocation == null && !hasUserRemovedLocation) {
view.displayAddLocationDialog { verifyCaptionQuality(uploadItem) }
} else {
verifyCaptionQuality(uploadItem)
}
}
/**
* Verifies the image's caption and calls function to handle the result
*
* @param uploadItem UploadItem whose caption is checked
*/
private fun verifyCaptionQuality(uploadItem: UploadItem) {
view.showProgress(true)
compositeDisposable.add(repository.getCaptionQuality(uploadItem)
.observeOn(mainThreadScheduler)
.subscribe({ capResult: Int ->
view.showProgress(false)
handleCaptionResult(capResult, uploadItem)
}, { throwable: Throwable ->
view.showProgress(false)
if (throwable is UnknownHostException) {
view.showConnectionErrorPopupForCaptionCheck()
} else {
view.showMessage(throwable.localizedMessage, R.color.color_error)
}
Timber.e(throwable, "Error occurred while handling image")
})
)
}
/**
* Handles image's caption results and shows dialog if necessary
*
* @param errorCode Error code of the UploadItem
* @param uploadItem UploadItem whose caption is checked
*/
fun handleCaptionResult(errorCode: Int, uploadItem: UploadItem) {
// If errorCode is empty caption show message
if (errorCode == EMPTY_CAPTION) {
Timber.d("Captions are empty. Showing toast")
view.showMessage(R.string.add_caption_toast, R.color.color_error)
}
// If image with same file name exists check the bit in errorCode is set or not
if ((errorCode and FILE_NAME_EXISTS) != 0) {
Timber.d("Trying to show duplicate picture popup")
view.showDuplicatePicturePopup(uploadItem)
}
// If caption is not duplicate or user still wants to upload it
if (errorCode == IMAGE_OK) {
Timber.d("Image captions are okay or user still wants to upload it")
view.onImageValidationSuccess()
}
}
/**
* Copies the caption and description of the current item to the subsequent media
*/
override fun copyTitleAndDescriptionToSubsequentMedia(indexInViewFlipper: Int) {
for (i in indexInViewFlipper + 1 until repository.getCount()) {
val subsequentUploadItem = repository.getUploads()[i]
subsequentUploadItem.uploadMediaDetails = deepCopy(
repository.getUploads()[indexInViewFlipper].uploadMediaDetails
).toMutableList()
}
}
/**
* Fetches and set the caption and description of the item
*/
override fun fetchTitleAndDescription(indexInViewFlipper: Int) =
view.updateMediaDetails(repository.getUploads()[indexInViewFlipper].uploadMediaDetails)
private fun deepCopy(uploadMediaDetails: List<UploadMediaDetail>) =
uploadMediaDetails.map(UploadMediaDetail::javaCopy)
override fun useSimilarPictureCoordinates(
imageCoordinates: ImageCoordinates, uploadItemIndex: Int
) = repository.useSimilarPictureCoordinates(imageCoordinates, uploadItemIndex)
override fun onMapIconClicked(indexInViewFlipper: Int) =
view.showExternalMap(repository.getUploads()[indexInViewFlipper])
override fun onEditButtonClicked(indexInViewFlipper: Int) =
view.showEditActivity(repository.getUploads()[indexInViewFlipper])
/**
* Updates the information regarding the specified place for the specified upload item
* when the user confirms the suggested nearby place.
*
* @param place The place to be associated with the uploads.
* @param uploadItemIndex Index of the uploadItem whose detected place has been confirmed
*/
override fun onUserConfirmedUploadIsOfPlace(place: Place?, uploadItemIndex: Int) {
val uploadItem = repository.getUploads()[uploadItemIndex]
uploadItem.place = place
val uploadMediaDetails = uploadItem.uploadMediaDetails
// Update UploadMediaDetail object for this UploadItem
uploadMediaDetails[0] = UploadMediaDetail(place)
// 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
view.updateMediaDetails(uploadMediaDetails)
setUploadIsOfAPlace(true)
}
/**
* Calculates the image quality
*
* @param uploadItemIndex Index of the UploadItem whose quality is to be checked
* @param inAppPictureLocation In app picture location (if any)
* @param activity Context reference
* @return true if no internal error occurs, else returns false
*/
override fun getImageQuality(
uploadItemIndex: Int,
inAppPictureLocation: LatLng?,
activity: Activity
): Boolean {
val uploadItems = repository.getUploads()
view.showProgress(true)
if (uploadItems.isEmpty()) {
view.showProgress(false)
// No internationalization required for this error message because it's an internal error.
view.showMessage(
"Internal error: Zero upload items received by the Upload Media Detail Fragment. Sorry, please upload again.",
R.color.color_error
)
return false
}
val uploadItem = uploadItems[uploadItemIndex]
compositeDisposable.add(repository.getImageQuality(uploadItem, inAppPictureLocation)
.observeOn(mainThreadScheduler)
.subscribe({ imageResult: Int ->
storeImageQuality(imageResult, uploadItemIndex, activity, uploadItem)
}, { throwable: Throwable ->
if (throwable is UnknownHostException) {
view.showProgress(false)
view.showConnectionErrorPopup()
} else {
view.showMessage(throwable.localizedMessage, R.color.color_error)
}
Timber.e(throwable, "Error occurred while handling image")
})
)
return true
}
/**
* Stores the image quality in JSON format in SharedPrefs
*
* @param imageResult Image quality
* @param uploadItemIndex Index of the UploadItem whose quality is calculated
* @param activity Context reference
* @param uploadItem UploadItem whose quality is to be checked
*/
private fun storeImageQuality(
imageResult: Int, uploadItemIndex: Int, activity: Activity, uploadItem: UploadItem
) {
val store = BasicKvStore(activity, UploadActivity.storeNameForCurrentUploadImagesSize)
val value = store.getString(UPLOAD_QUALITIES_KEY, null)
try {
val jsonObject = value.asJsonObject().apply {
put("UploadItem$uploadItemIndex", imageResult)
}
store.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
} catch (e: Exception) {
Timber.e(e)
}
if (uploadItemIndex == 0) {
if (!isBatteryDialogShowing && !isCategoriesDialogShowing) {
// if battery-optimisation dialog is not being shown, call checkImageQuality
checkImageQuality(uploadItem, uploadItemIndex)
} else {
view.showProgress(false)
}
}
}
/**
* Used to check image quality from stored qualities and display dialogs
*
* @param uploadItem UploadItem whose quality is to be checked
* @param index Index of the UploadItem whose quality is to be checked
*/
override fun checkImageQuality(uploadItem: UploadItem, index: Int) {
if ((uploadItem.imageQuality != IMAGE_OK) && (uploadItem.imageQuality != IMAGE_KEEP)) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
try {
val imageQuality = value.asJsonObject()["UploadItem$index"] as Int
view.showProgress(false)
if (imageQuality == IMAGE_OK) {
uploadItem.hasInvalidLocation = false
uploadItem.imageQuality = imageQuality
} else {
handleBadImage(imageQuality, uploadItem, index)
}
} catch (e: Exception) {
Timber.e(e)
}
}
}
/**
* Updates the image qualities stored in JSON, whenever an image is deleted
*
* @param size Size of uploadableFiles
* @param index Index of the UploadItem which was deleted
*/
override fun updateImageQualitiesJSON(size: Int, index: Int) {
val value = basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.getString(UPLOAD_QUALITIES_KEY, null)
try {
val jsonObject = value.asJsonObject().apply {
for (i in index until (size - 1)) {
put("UploadItem$i", this["UploadItem" + (i + 1)])
}
remove("UploadItem" + (size - 1))
}
basicKvStoreFactory(UploadActivity.storeNameForCurrentUploadImagesSize)
.putString(UPLOAD_QUALITIES_KEY, jsonObject.toString())
} catch (e: Exception) {
Timber.e(e)
}
}
/**
* Handles bad pictures, like too dark, already on wikimedia, downloaded from internet
*
* @param errorCode Error code of the bad image quality
* @param uploadItem UploadItem whose quality is bad
* @param index Index of item whose quality is bad
*/
private fun handleBadImage(
errorCode: Int,
uploadItem: UploadItem, index: Int
) {
Timber.d("Handle bad picture with error code %d", errorCode)
if (errorCode >= 8) { // If location of image and nearby does not match
uploadItem.hasInvalidLocation = true
}
// If image has some other problems, show popup accordingly
if (errorCode != EMPTY_CAPTION && errorCode != FILE_NAME_EXISTS) {
view.showBadImagePopup(errorCode, index, uploadItem)
}
}
/**
* notifies the user that a similar image exists
*/
override fun showSimilarImageFragment(
originalFilePath: String?,
possibleFilePath: String?,
similarImageCoordinates: ImageCoordinates?
) = view.showSimilarImageFragment(originalFilePath, possibleFilePath, similarImageCoordinates)
private fun String?.asJsonObject() = if (this != null) {
JSONObject(this)
} else {
JSONObject()
}
companion object {
private const val UPLOAD_QUALITIES_KEY = "UploadedImagesQualities"
private val WLM_SUPPORTED_COUNTRIES = listOf(
"am", "at", "az", "br", "hr", "sv", "fi", "fr", "de", "gh",
"in", "ie", "il", "mk", "my", "mt", "pk", "pe", "pl", "ru",
"rw", "si", "es", "se", "tw", "ug", "ua", "us"
)
private val DUMMY = Proxy.newProxyInstance(
UploadMediaDetailsContract.View::class.java.classLoader,
arrayOf<Class<*>>(UploadMediaDetailsContract.View::class.java)
) { _: Any?, _: Method?, _: Array<Any?>? -> null } as UploadMediaDetailsContract.View
var presenterCallback: UploadMediaDetailFragmentCallback? = null
/**
* Variable used to determine if the battery-optimisation dialog is being shown or not
*/
var isBatteryDialogShowing: Boolean = false
var isCategoriesDialogShowing: Boolean = false
}
}

View file

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

View file

@ -481,8 +481,8 @@ class UploadWorker(
)
if (null != revisionID) {
withContext(Dispatchers.IO) {
val place = placesRepository.fetchPlace(wikiDataPlace.id);
place.name = wikiDataPlace.name;
val place = placesRepository.fetchPlace(wikiDataPlace.id)
place.name = wikiDataPlace.name
place.pic = HOME_URL + uploadResult.createCanonicalFileName()
placesRepository
.save(place)
@ -568,12 +568,18 @@ class UploadWorker(
sequenceFileName =
if (fileName.indexOf('.') == -1) {
"$fileName #$randomHash"
// Append the random hash in parentheses if no file extension is present
"$fileName ($randomHash)"
} else {
val regex =
Pattern.compile("^(.*)(\\..+?)$")
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!!

View file

@ -16,6 +16,9 @@ import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
import fr.free.nrw.commons.R
import fr.free.nrw.commons.upload.UploadActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
object PermissionUtils {
@ -130,7 +133,7 @@ object PermissionUtils {
vararg permissions: String
) {
if (hasPartialAccess(activity)) {
Thread(onPermissionGranted).start()
CoroutineScope(Dispatchers.Main).launch { onPermissionGranted.run() }
return
}
checkPermissionsAndPerformAction(
@ -166,13 +169,15 @@ object PermissionUtils {
rationaleMessage: Int,
vararg permissions: String
) {
val scope = CoroutineScope(Dispatchers.Main)
Dexter.withActivity(activity)
.withPermissions(*permissions)
.withListener(object : MultiplePermissionsListener {
override fun onPermissionsChecked(report: MultiplePermissionsReport) {
when {
report.areAllPermissionsGranted() || hasPartialAccess(activity) ->
Thread(onPermissionGranted).start()
scope.launch { onPermissionGranted.run() }
report.isAnyPermissionPermanentlyDenied -> {
DialogUtil.showAlertDialog(
activity,
@ -189,7 +194,7 @@ object PermissionUtils {
null, null
)
}
else -> Thread(onPermissionDenied).start()
else -> scope.launch { onPermissionDenied?.run() }
}
}
@ -208,7 +213,7 @@ object PermissionUtils {
activity.getString(android.R.string.cancel),
{
if (activity is UploadActivity) {
activity.setShowPermissionsDialog(true)
activity.isShowPermissionsDialog = true
}
token.continuePermissionRequest()
},

View file

@ -21,9 +21,14 @@ abstract class SwipableCardView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : CardView(context, attrs, defStyleAttr) {
companion object{
const val MINIMUM_THRESHOLD_FOR_SWIPE = 100f
}
private var x1 = 0f
private var x2 = 0f
private val MINIMUM_THRESHOLD_FOR_SWIPE = 100f
init {
interceptOnTouchListener()

View file

@ -1,6 +1,9 @@
package fr.free.nrw.commons.wikidata.mwapi
import com.google.gson.annotations.SerializedName
open class MwPostResponse : MwResponse() {
@SerializedName("success")
val successVal: Int = 0
fun success(result: String?): Boolean =

View file

@ -34,7 +34,7 @@ class MwQueryPage : BaseModel() {
fun title(): String = title!!
fun categoryInfo(): CategoryInfo = categoryinfo!!
fun categoryInfo(): CategoryInfo? = categoryinfo
fun index(): Int = index

View file

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:fillColor="?attr/more_bottom_sheet_drawable_color"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

View file

@ -12,7 +12,7 @@
android:layout_width="@dimen/landscape_width"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="@dimen/small_gap">
android:layout_marginTop="@dimen/login_gap">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"

View file

@ -2,12 +2,14 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:layout_gravity="center_vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="vertical"
android:layout_gravity="center_vertical">
<FrameLayout
android:layout_width="@dimen/landscape_width"

View file

@ -1,13 +1,15 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:layout_gravity="center_vertical">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fillViewport="true"
android:orientation="vertical">
android:orientation="vertical"
android:layout_gravity="center_vertical">
<FrameLayout
android:layout_width="wrap_content"

View file

@ -132,15 +132,32 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/btn_submit_feedback"
android:textColor="@color/white"
android:layout_marginBottom="@dimen/dimen_10"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/dimen_10"
android:gravity="center"
android:layout_gravity="center"
android:text="@string/submit"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
android:orientation="horizontal">
<Button
android:id="@+id/btn_cancel"
android:textColor="@color/white"
android:layout_gravity="center"
android:layout_weight="1"
android:text="@string/cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<Button
android:id="@+id/btn_submit_feedback"
android:textColor="@color/white"
android:layout_weight="1"
android:layout_gravity="center"
android:text="@string/submit"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</ScrollView>

View file

@ -66,9 +66,18 @@
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:id="@+id/language_list"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toTopOf="@id/cancel_button"
app:layout_constraintTop_toBottomOf="@+id/all_languages" />
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/cancel_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/cancel"
android:textColor="@color/primaryColor"
android:background="@android:color/transparent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

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

View file

@ -49,6 +49,20 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/all_images_uploaded_or_marked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:textSize="16sp"
android:padding="@dimen/standard_gap"
android:textColor="@color/text_color_selector"
android:text="@string/congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
<ProgressBar
android:id="@+id/loader"

View file

@ -117,12 +117,12 @@
android:layout_alignParentRight="true"
android:clickable="true"
android:visibility="visible"
app:backgroundTint="@color/main_background_light"
android:layout_margin="16dp"
app:backgroundTint="?attr/mainBackground"
app:elevation="@dimen/dimen_6"
app:fabSize="normal"
app:layout_anchorGravity="top|right|end"
app:srcCompat="@drawable/ic_my_location_black_24dp"
app:useCompatPadding="true" />
app:srcCompat="@drawable/ic_my_location_black_24dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_legend"
@ -133,12 +133,12 @@
android:layout_alignParentRight="true"
android:clickable="true"
android:visibility="visible"
app:backgroundTint="@color/main_background_light"
android:layout_margin="16dp"
app:backgroundTint="?attr/mainBackground"
app:elevation="@dimen/dimen_6"
app:fabSize="normal"
app:layout_anchorGravity="top|right|end"
app:srcCompat="@drawable/ic_info_outline_24dp"
app:useCompatPadding="true" />
app:srcCompat="@drawable/ic_info_outline_24dp" />
<include
android:id="@+id/nearby_legend_layout"

View file

@ -331,6 +331,7 @@
<string name="copy_wikicode">انسخ نص الويكي إلى الكليب بورد</string>
<string name="wikicode_copied">نص الويكي تم نسخه إلى الكليب بورد</string>
<string name="nearby_location_not_available">قد لا تعمل الأجهزة المجاورة بشكل صحيح، الموقع غير متوفر.</string>
<string name="nearby_showing_pins_offline">الإنترنت غير متاح. يتم عرض الأماكن المخزنة مؤقتًا فقط.</string>
<string name="upload_location_access_denied">رُفض الوصول إلى الموقع. يرجى ضبط موقعك يدويًا لاستخدام هذه الميزة.</string>
<string name="location_permission_rationale_nearby">صلاحية مطلوبة لعرض قائمة بالأماكن القريبة</string>
<string name="location_permission_rationale_explore">الإذن مطلوب لعرض قائمة الصور القريبة</string>
@ -874,4 +875,12 @@
<string name="usages_on_commons_heading">كومنز</string>
<string name="usages_on_other_wikis_heading">مواقع ويكي أخرى</string>
<string name="file_usages_container_heading">حالات استخدام الملف</string>
<string name="title_activity_single_web_view">نشاط عرض ويب واحد</string>
<string name="account">حساب</string>
<string name="vanish_account">حذف الحساب</string>
<string name="account_vanish_request_confirm_title">تحذير من اختفاء الحساب</string>
<string name="account_vanish_request_confirm">الاختفاء هو &lt;b&gt;الملاذ الأخير&lt;/b&gt; ويجب &lt;b&gt;استخدامه فقط عندما ترغب في التوقف عن التحرير إلى الأبد&lt;/b&gt; وأيضًا لإخفاء أكبر عدد ممكن من ارتباطاتك السابقة.&lt;br/&gt;&lt;br/&gt; يتم حذف الحساب على ويكيميديا كومنز عن طريق تغيير اسم حسابك بحيث لا يتمكن الآخرون من التعرف على مساهماتك في عملية تسمى اختفاء الحساب. &lt;b&gt;لا يضمن الاختفاء عدم الكشف عن الهوية تمامًا أو إزالة المساهمات في المشاريع&lt;/b&gt; .</string>
<string name="caption">الشرح</string>
<string name="caption_copied_to_clipboard">تم نسخ التسمية التوضيحية إلى الحافظة</string>
<string name="congratulations_all_pictures_in_this_album_have_been_either_uploaded_or_marked_as_not_for_upload">مبروك، جميع الصور الموجودة في هذا الألبوم تم تحميلها أو تم وضع علامة عليها بأنها غير قابلة للتحميل.</string>
</resources>

View file

@ -53,9 +53,11 @@
<string name="signup">Qeydiyyatdan keç</string>
<string name="logging_in_title">Giriş edilir</string>
<string name="logging_in_message">Zəhmət olmasa, gözləyin…</string>
<string name="login_success" fuzzy="true">Daxil oldunuz!</string>
<string name="login_success">Uğurlu giriş!</string>
<string name="login_failed">Giriş baş tutmadı!</string>
<string name="upload_failed">Fayl tapılmadı. Xahiş edirik başqa bir fayl üzərində cəhd edin.</string>
<string name="retry_limit_reached">Maksimum təkrar cəhd limitinə çatdınız! Zəhmət olmasa, yükləməni ləğv edin və yenidən cəhd edin</string>
<string name="unrestricted_battery_mode">Batareya optimallaşdırılması söndürülsün?</string>
<string name="authentication_failed" fuzzy="true">Doğrulama alınmadı, xahiş edirəm yenidən daxil olun</string>
<string name="uploading_started">Yükləmə başladı!</string>
<string name="upload_completed_notification_title">%1$s yükləndi!</string>

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