Merge branch 'main' into issue_5832

This commit is contained in:
Nicolas Raoul 2025-01-13 23:06:39 +09:00 committed by GitHub
commit dec5ac8e8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 3890 additions and 3836 deletions

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"

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" />

View file

@ -0,0 +1,181 @@
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.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 timber.log.Timber
/**
* 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() {
@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
}
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)
}
}
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
}
)
}
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

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

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

@ -271,7 +271,7 @@ class CustomSelectorActivity :
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()
}
@ -687,8 +687,8 @@ class CustomSelectorActivity :
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

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

View file

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

@ -1586,7 +1586,7 @@ class MediaDetailFragment : CommonsDaggerSupportFragment(), CategoryEditHelper.C
mediaDetail: UploadMediaDetail,
updatedCaptions: MutableMap<String, String>
) {
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText!!
media!!.captions = updatedCaptions
}

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

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

@ -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 APP_UI_LANGUAGE = "appUiLanguage"
const val KEY_THEME_VALUE = "appThemePref"

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,7 +10,6 @@ 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
@ -21,6 +19,7 @@ import android.widget.TextView
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
@ -36,6 +35,7 @@ import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
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 +50,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 +74,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 appUiLanguageListPreference: Preference? = null
@ -81,6 +83,7 @@ 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"
@ -116,6 +119,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
)
@ -131,7 +154,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
inAppCameraLocationPref?.setOnPreferenceChangeListener { _, newValue ->
val isInAppCameraLocationTurnedOn = newValue as Boolean
if (isInAppCameraLocationTurnedOn) {
createDialogsAndHandleLocationPermissions(requireActivity())
createDialogsAndHandleLocationPermissions()
}
true
}
@ -256,7 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
*
* @param activity
*/
private fun createDialogsAndHandleLocationPermissions(activity: Activity) {
private fun createDialogsAndHandleLocationPermissions() {
inAppCameraLocationPermissionLauncher.launch(arrayOf(permission.ACCESS_FINE_LOCATION))
}
@ -284,7 +307,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
}
@ -487,7 +509,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
editor.apply()
}
@Suppress("LongLine")
companion object {
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,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

@ -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,986 +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))
.setCancelable(false)
.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,947 @@
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("Received intent %s with action %s", intent.toString(), intent.action)
uploadableFiles = mutableListOf<UploadableFile>().apply {
addAll(intent.getParcelableArrayListExtra(EXTRA_FILES) ?: emptyList())
}
isMultipleFilesSelected = uploadableFiles!!.size > 1
Timber.i("Received multiple upload %s", uploadableFiles!!.size)
place = intent.getParcelableExtra<Place>(PLACE_OBJECT)
prevLocation = intent.getParcelableExtra(LOCATION_BEFORE_IMAGE_CAPTURE)
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
*/
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_exta"
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,19 +18,19 @@ 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.
*/
var captionText: String = "",
var captionText: String? = "",
) : 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,633 +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.Button;
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);
final Button cancelButton = dialog.findViewById(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);
setUpRecentLanguagesSection(recentLanguages);
listView.setAdapter(languagesAdapter);
cancelButton.setOnClickListener(v -> dialog.dismiss());
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

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

View file

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

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

@ -208,7 +208,7 @@ object PermissionUtils {
activity.getString(android.R.string.cancel),
{
if (activity is UploadActivity) {
activity.setShowPermissionsDialog(true)
activity.isShowPermissionsDialog = true
}
token.continuePermissionRequest()
},

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

@ -875,4 +875,6 @@
<string name="usages_on_commons_heading">كومنز</string>
<string name="usages_on_other_wikis_heading">مواقع ويكي أخرى</string>
<string name="file_usages_container_heading">حالات استخدام الملف</string>
<string name="caption">الشرح</string>
<string name="caption_copied_to_clipboard">تم نسخ التسمية التوضيحية إلى الحافظة</string>
</resources>

View file

@ -56,8 +56,8 @@
<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! Yükləməni ləğv edin və yenidən cəhd edin</string>
<string name="unrestricted_battery_mode">Batareya optimallaşdırılmasını söndürmək?</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>

View file

@ -529,4 +529,6 @@
<string name="uploads">আপলোড</string>
<string name="pending">অমীমাংসিত</string>
<string name="failed">ব্যর্থ হয়েছে</string>
<string name="caption">ক্যাপশন</string>
<string name="caption_copied_to_clipboard">ক্যাপশন ক্লিপবোর্ডে অনুলিপি করা হয়েছে</string>
</resources>

View file

@ -814,4 +814,10 @@
<string name="usages_on_other_wikis_heading">Andre wikier</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">Filanvendelser</string>
<string name="account">Konto</string>
<string name="vanish_account">Få konto til at forsvinde</string>
<string name="account_vanish_request_confirm_title">Advarsel om forsvinding af konto</string>
<string name="account_vanish_request_confirm">Forsvinding er en &lt;b&gt;sidste udvej&lt;/b&gt; og bør &lt;b&gt;kun bruges, når du for altid ønsker at stoppe med at redigere&lt;/b&gt; og også for at skjule så mange af dine tidligere tilknytninger som muligt.&lt;br/&gt;&lt;br/&gt; Kontosletning på Wikipedia Commons sker ved at ændre dit kontonavn, således at andre ikke kan genkende dine bidrag i en proces, der kaldes kontoforsvinding (Vanishing). &lt;b&gt;Forsvinding garanterer ikke fuldstændig anonymitet eller fjerner bidrag til projekterne&lt;/b&gt; .</string>
<string name="caption">Billedtekst</string>
<string name="caption_copied_to_clipboard">Billedtekst kopieret til udklipsholder</string>
</resources>

View file

@ -32,6 +32,7 @@
* Orikrin1998
* Paraboule
* Patrick Star
* Pols12
* Robins7
* Sherbrooke
* SleaY
@ -852,4 +853,6 @@
<string name="usages_on_other_wikis_heading">Autres wikis</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">Utilisations du fichier</string>
<string name="caption">Légende</string>
<string name="caption_copied_to_clipboard">Légende copiée dans le presse-papier</string>
</resources>

View file

@ -358,4 +358,5 @@
<string name="custom_selector_delete_folder">फ़ोल्डर हटाएँ</string>
<string name="custom_selector_delete">हटाएँ</string>
<string name="custom_selector_cancel">रद्द करें</string>
<string name="caption">कैप्शन</string>
</resources>

View file

@ -100,6 +100,8 @@
<string name="menu_from_camera">Prender photo</string>
<string name="menu_nearby">A proximitate</string>
<string name="provider_contributions">Mi incargamentos</string>
<string name="menu_copy_link">Copiar ligamine</string>
<string name="menu_link_copied">Le ligamine ha essite copiate al area de transferentia</string>
<string name="menu_share">Condivider</string>
<string name="menu_view_file_page">Visitar le pagina del file</string>
<string name="share_title_hint">Legenda (obligatori)</string>
@ -118,6 +120,7 @@
<string name="categories_search_text_hint">Cercar categorias</string>
<string name="depicts_search_text_hint">Cerca elementos que tu file representa (montania, Taj Mahal, etc.)</string>
<string name="menu_save_categories">Salveguardar</string>
<string name="menu_overflow_desc">Menu de disbordamento</string>
<string name="refresh_button">Refrescar</string>
<string name="display_list_button">Listar</string>
<string name="contributions_subtitle_zero">(Nihil incargate ancora)</string>
@ -269,6 +272,7 @@
<string name="copy_wikicode">Copiar le wikitexto al area de transferentia</string>
<string name="wikicode_copied">Le wikitexto ha essite copiate al area de transferentia</string>
<string name="nearby_location_not_available">“A proximitate” poterea non functionar perque le localisation non es disponibile.</string>
<string name="nearby_showing_pins_offline">Internet indisponibile. Appare solmente le locos in cache.</string>
<string name="upload_location_access_denied">Le accesso al localisation ha essite refusate. Per favor indica tu localisation manualmente pro usar iste function.</string>
<string name="location_permission_rationale_nearby">Permission necessari pro monstrar un lista de locos a proximitate</string>
<string name="location_permission_rationale_explore">Permission necessari pro monstrar un lista de imagines a proximitate</string>
@ -352,11 +356,13 @@
<string name="delete">Deler</string>
<string name="Achievements">Realisationes</string>
<string name="Profile">Profilo</string>
<string name="badges">Insignias</string>
<string name="statistics">Statisticas</string>
<string name="statistics_thanks">Regratiamentos recipite</string>
<string name="statistics_featured">Imagines eminente</string>
<string name="statistics_wikidata_edits">Imagines via “Locos a proximitate”</string>
<string name="level" fuzzy="true">Nivello</string>
<string name="level">Nivello %d</string>
<string name="profileLevel">%s (Nivello %s)</string>
<string name="images_uploaded">Imagines incargate</string>
<string name="image_reverts">Imagines non revertite</string>
<string name="images_used_by_wiki">Imagines usate</string>
@ -388,6 +394,7 @@
<string name="map_application_missing">Necun application cartographic compatibile pote esser trovate sur tu apparato. Per favor installa un application cartographic pro usar iste function.</string>
<string name="title_page_bookmarks_pictures">Imagines</string>
<string name="title_page_bookmarks_locations">Locos</string>
<string name="title_page_bookmarks_categories">Categorias</string>
<string name="menu_bookmark">Adder al/Remover del marcapaginas</string>
<string name="provider_bookmarks">Marcapaginas</string>
<string name="bookmark_empty">Tu non ha addite alcun marcapagina</string>
@ -469,6 +476,7 @@
<string name="no_notification">Tu non ha notificationes non legite</string>
<string name="no_read_notification">Tu non ha notificationes legite</string>
<string name="share_logs_using">Condivider registros usante</string>
<string name="check_your_email_inbox">Consulta tu cassa de entrata</string>
<string name="menu_option_read">Vider legites</string>
<string name="menu_option_unread">Vider non legites</string>
<string name="error_occurred_in_picking_images">Un error ha occurrite durante le selection de imagines</string>
@ -776,4 +784,21 @@
<string name="pending">Pendente</string>
<string name="failed">Fallite</string>
<string name="could_not_load_place_data">Non poteva cargar le datos del loco</string>
<string name="custom_selector_delete_folder">Deler dossier</string>
<string name="custom_selector_confirm_deletion_title">Confirmar deletion</string>
<string name="custom_selector_confirm_deletion_message">Es tu secur de voler deler le dossier %1$s que contine %2$d objectos?</string>
<string name="custom_selector_delete">Deler</string>
<string name="custom_selector_cancel">Cancellar</string>
<string name="custom_selector_folder_deleted_success">Le dossier %1$s ha essite delite</string>
<string name="custom_selector_folder_deleted_failure">Le dossier %1$s non ha potite esser delite</string>
<string name="custom_selector_error_trashing_folder_contents">Error durante le elimination del contento del dossier: %1$s</string>
<string name="custom_selector_folder_not_found_error">Non poteva recuperar le percurso al dossier pro le “bucket ID” %1$d</string>
<string name="red_pin">Iste loco non ha ancora un photo. Proque non prender un?</string>
<string name="green_pin">Iste loco ja ha un photo.</string>
<string name="grey_pin">Ora se verifica si iste loco ha un photo.</string>
<string name="error_while_loading">Error durante le cargamento</string>
<string name="no_usages_found">Necun uso trovate</string>
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Altere wikis</string>
<string name="file_usages_container_heading">Usos del file</string>
</resources>

View file

@ -773,4 +773,6 @@
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Altri wiki</string>
<string name="file_usages_container_heading">Utilizzi del file</string>
<string name="caption">Didascalia</string>
<string name="caption_copied_to_clipboard">Didascalia copiata negli appunti</string>
</resources>

View file

@ -846,4 +846,9 @@
<string name="usages_on_other_wikis_heading">אתרי ויקי אחרים</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">שימושים בקובץ</string>
<string name="account">חשבון</string>
<string name="vanish_account">העלמת חשבון</string>
<string name="account_vanish_request_confirm_title">אזהרת העלמת חשבון</string>
<string name="caption">כותרת</string>
<string name="caption_copied_to_clipboard">הכותרת הועתקה ללוח</string>
</resources>

View file

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

View file

@ -141,4 +141,5 @@
<string name="label">Аталыш</string>
<string name="description">Сыпаттама</string>
<string name="title_page_bookmarks_items">Элементтер</string>
<string name="account">Аккаунт</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Authors:
* GilPe
* Les Meloures
* Robby
* Soued031
@ -532,4 +533,8 @@
<string name="error_while_loading">Feeler beim Lueden</string>
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Aner Wikien</string>
<string name="account">Kont</string>
<string name="vanish_account">Kont opléisen</string>
<string name="caption">Beschrëftung</string>
<string name="caption_copied_to_clipboard">Text an den Tëschespäicher kopéiert</string>
</resources>

View file

@ -810,4 +810,11 @@
<string name="usages_on_other_wikis_heading">Други викија</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">Употреби на податотеката</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</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; и треба да се користи само ако сакате да престанете да уредувате засекогаш/b&gt; и да скриете што повеќе од вашите досегашни врски.&lt;br/&gt;&lt;br/&gt;Бришењето сметки на Википедија се врши со менување на името на вашата сметка, така што другите не би можеле да ги препознаат вашите придонеси во постапка наречена „исчезнување“ на сметка.&lt;b&gt;Исчезнувањето не гарантира целосна анонимност и не ги отстранува придонесите на проектите&lt;/b&gt;.</string>
<string name="caption">Толкување</string>
<string name="caption_copied_to_clipboard">Толкувањето е ставено во меѓускладот</string>
</resources>

View file

@ -51,7 +51,7 @@
<string name="upload_failed_notification_title" fuzzy="true"> %1$s उर्ध्वभरण असफल भयो</string>
<string name="upload_failed_notification_subtitle">हेर्नको लागि ट्याप गर्नुहोस्</string>
<string name="upload_paused_notification_subtitle">हेर्नको लागि ट्याप गर्नुहोस्</string>
<string name="title_activity_contributions">मेरा हालैका उर्ध्वभरणहरू</string>
<string name="title_activity_contributions">मेरा वर्तमानका उर्ध्वभरणहरू</string>
<string name="contribution_state_queued">लाममा राखियो</string>
<string name="contribution_state_failed">असफल भयो</string>
<string name="contribution_state_in_progress">%1$d%% पूरा भयो</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Authors:
* A67-A67
* ABPMAB
* Bouman4
* Ciell
* Dutchy45
@ -299,6 +300,7 @@
<string name="copy_wikicode">Kopieer de wikitekst naar het klembord</string>
<string name="wikicode_copied">De wikitekst is gekopieerd naar het klembord</string>
<string name="nearby_location_not_available">In de buurt werkt mogelijk niet naar behoren. Locatie niet beschikbaar.</string>
<string name="nearby_showing_pins_offline">Internet niet beschikbaar. Alleen gecachte plaatsen worden getoond.</string>
<string name="upload_location_access_denied">Locatietoegang geweigerd. Stel uw locatie handmatig in om deze functie te gebruiken.</string>
<string name="location_permission_rationale_nearby">Toestemming vereist om een lijst van nabije plaatsen weer te geven</string>
<string name="location_permission_rationale_explore">Toestemming vereist om een lijst van nabije afbeeldingen weer te geven</string>

View file

@ -119,7 +119,7 @@
<string name="license_name_cc_by_4_0">CC BY 4.0</string>
<string name="tutorial_1_text">ਵਿਕੀਮੀਡੀਆ ਕਾਮਨਜ਼ ਜ਼ਿਆਦਾਤਰ ਉਹ ਤਸਵੀਰਾਂ ਦਾ ਭੰਡਾਰ ਹੈ ਜੋ ਵਿਕੀਪੀਡੀਆ \'ਤੇ ਵਰਤੀਆਂ ਜਾਂਦੀਆਂ ਹਨ।</string>
<string name="tutorial_1_subtext">ਤੁਹਾਡੀਆਂ ਤਸਵੀਰਾਂ ਵਿਸ਼ਵ ਦੇ ਬਾਕੀ ਲੋਕਾਂ ਨੂੰ ਸਿੱਖਿਅਤ ਕਰਨ ਲਈ ਸਹਾਈ ਹਨ!</string>
<string name="tutorial_2_text">ਕਿਰਪਾ ਕਰਕੇ ਉਹ ਤਸਵੀਰਾਂ ਅਪਲੋਡ ਕਰੋ ਜੋ ਤੁਹਾਡੇ ਦੁਆਰਾ ਲਈਆਂ ਗਈਆਂ ਹਨ ਜਾਂ ਬਣਾਈਆਂ ਗਈਆਂ ਹਨ:</string>
<string name="tutorial_2_text">ਕਿਰਪਾ ਕਰਕੇ ਉਹ ਤਸਵੀਰਾਂ ਨੂੰ ਚੜ੍ਹਾਉ ਜੋ ਤੁਹਾਡੇ ਵੱਲੋਂ ਲਈਆਂ ਗਈਆਂ ਹਨ ਜਾਂ ਬਣਾਈਆਂ ਗਈਆਂ ਹਨ:</string>
<string name="tutorial_3_text">ਕਿਰਪਾ ਕਰਕੇ ਅਪਲੋਡ ਨਾ ਕਰੋ:</string>
<string name="tutorial_4_text">ਉਦਾਹਰਣ ਵਜੋਂ ਇਹ ਅਪਲੋਡ:</string>
<string name="welcome_wikipedia_text">ਆਪਣੀਆਂ ਤਸਵੀਰਾਂ ਦਾ ਯੋਗਦਾਨ ਪਾਓ। ਵਿਕੀਪੀਡੀਆ ਲੇਖਾਂ ਨੂੰ ਸੁਰਜੀਤ ਕਰ ਦਿਓ!</string>
@ -160,7 +160,7 @@
<string name="navigation_item_upload">ਚੜ੍ਹਾਉ</string>
<string name="navigation_item_nearby">ਨੇੜੇ-ਤੇੜੇ</string>
<string name="navigation_item_about">ਬਾਰੇ</string>
<string name="navigation_item_settings">ਸੈਟਿੰਗਾਂ</string>
<string name="navigation_item_settings">ਤਰਜੀਹਾਂ</string>
<string name="navigation_item_feedback">ਸੁਝਾਅ</string>
<string name="navigation_item_logout">ਬਾਹਰ ਆਉ</string>
<string name="navigation_item_info">ਸਿਖਲਾਈ</string>

View file

@ -806,4 +806,11 @@
<string name="usages_on_other_wikis_heading">Àutre wiki</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">Usagi dl\'archivi</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</string>
<string name="account">Cont</string>
<string name="vanish_account">Flambé ël cont</string>
<string name="account_vanish_request_confirm_title">Avis d\'eliminassion dël cont</string>
<string name="account_vanish_request_confirm">L\'eliminassion a l\'é &lt;b&gt;l\'ùltima arsorsa&lt;/b&gt; e a dovrìa &lt;b&gt;esse dovrà mach si chiel a veul chité ëd modifiché për sempe&lt;/b&gt; e ëdcò s\'a veul ëstërmé pi che possìbil soe assossiassion passà.&lt;br/&gt;&lt;br/&gt;La dëscancelassion ëd cont su Wikimedia a l\'é fàita an modificand sò stranòm an manera che j\'àutri a peulo pa arconòsse soe contribussion ant un process ciamà dëscancelassion ëd cont. &lt;b&gt;La sparission a garantiss pa l\'anonimà complet ni a gava le contribussion dai proget&lt;/b&gt;.</string>
<string name="caption">Legenda</string>
<string name="caption_copied_to_clipboard">Legenda copià an sla taulëtta</string>
</resources>

View file

@ -869,4 +869,7 @@
<string name="usages_on_commons_heading">Викисклад</string>
<string name="usages_on_other_wikis_heading">Другие вики</string>
<string name="file_usages_container_heading">Использование файла</string>
<string name="account">Учётная запись</string>
<string name="caption">Подпись</string>
<string name="caption_copied_to_clipboard">Подпись скопирована в буфер обмена</string>
</resources>

View file

@ -144,7 +144,7 @@
<string name="welcome_help_button_text">Lassedieđut</string>
<string name="detail_panel_cats_label">Kategoriijat</string>
<string name="detail_panel_cats_loading">Luđeme...</string>
<string name="detail_panel_cats_none">Ii guhtege válljejuvvon</string>
<string name="detail_panel_cats_none">Ii oktage válljejuvvon</string>
<string name="detail_caption_empty">Ii leat govvateaksta</string>
<string name="detail_description_empty">Ii gávdno govvádus</string>
<string name="detail_discussion_empty">Ii digaštallojuvvo</string>
@ -217,6 +217,9 @@
<string name="next">Čuovvovaš</string>
<string name="title_page_bookmarks_pictures">Govat</string>
<string name="title_page_bookmarks_locations">Sajit</string>
<string name="title_page_bookmarks_categories">Kategoriijat</string>
<string name="provider_bookmarks">Girjemearkkat</string>
<string name="provider_bookmarks_location">Girjemearkkat</string>
<string name="no_categories_selected">Ii oktage kategoriija leat válljejuvvon</string>
<string name="no_depictions_selected">Ii oktage govvádus leat válljejuvvon</string>
<string name="back_button_warning">Gaskkalduhte fiilla vurkema</string>
@ -232,9 +235,11 @@
<string name="caption_edit_helper_edit_message_else">Govvateavstta lasiheapmi ii lihkostuvvan.</string>
<string name="description_activity_title">Rievdat govvádusaid ja govvateavsttaid</string>
<string name="nearby_search_hint">Šaldi, musea, hotealla jna.</string>
<string name="title_app_shortcut_bookmark">Girjemearkkat</string>
<string name="load_more">Luđe lasi</string>
<string name="add_picture_to_wikipedia_article_title">Lasit gova Wikipediai</string>
<string name="resume">joatkke</string>
<string name="bookmarks">Girjemearkkat</string>
<string name="leaderboard_column_user">Geavaheaddji</string>
<string name="cancelling_upload">Fiilla vurkema gaskkalduhttomin…</string>
<string name="license_step_title">Medialiseansa</string>

View file

@ -277,4 +277,6 @@
<string name="custom_selector_delete">مٹاؤ</string>
<string name="custom_selector_cancel">منسوخ</string>
<string name="usages_on_commons_heading">کامنز</string>
<string name="account">کھاتہ</string>
<string name="caption">عنوان</string>
</resources>

View file

@ -743,4 +743,9 @@
<string name="failed">Није успело</string>
<string name="green_pin">Ово место већ има слику</string>
<string name="grey_pin">Проверавање да ли ово место има слику.</string>
<string name="account">Налог</string>
<string name="vanish_account">Учтиви нестанак</string>
<string name="account_vanish_request_confirm">Нестајање је &lt;b&gt;последња опција&lt;/b&gt; и треба је користити &lt;b&gt;само када желите да заувек престанете са уређивањем&lt;/b&gt;, као и да сакријете што више својих прошлих асоцијација.&lt;br/&gt;&lt;br/&gt;Брисање налога на Викимедијној остави се врши променом назива налога тако да други не могу да препознају Ваше доприносе у процесу који се зове учтиви нестанак. &lt;b&gt;Нестајање не гарантује потпуну анонимност и не уклања доприносе на пројектима&lt;/b&gt;.</string>
<string name="caption">Поднапис</string>
<string name="caption_copied_to_clipboard">Поднапис копиран</string>
</resources>

View file

@ -107,6 +107,8 @@
<string name="menu_from_camera">Ta foto</string>
<string name="menu_nearby">I närheten</string>
<string name="provider_contributions">Mina uppladdningar</string>
<string name="menu_copy_link">Kopiera länk</string>
<string name="menu_link_copied">Länken har kopierats till urklipp</string>
<string name="menu_share">Dela</string>
<string name="menu_view_file_page">Visa filsida</string>
<string name="share_title_hint">Bildtext (obligatoriskt)</string>
@ -282,6 +284,7 @@
<string name="copy_wikicode">Kopiera wikitexten till urklipp</string>
<string name="wikicode_copied">Wikitexten kopierades till urklipp</string>
<string name="nearby_location_not_available">\"I närheten\" kanske inte fungerar ordentligt. Platsen är inte tillgänglig.</string>
<string name="nearby_showing_pins_offline">Internet inte tillgängligt. Visar endast cachade platser.</string>
<string name="upload_location_access_denied">Platsåtkomst nekad. Ange din plats manuellt för att använda den här funktionen.</string>
<string name="location_permission_rationale_nearby">Behörighet krävs för att visa en lista över platser i närheten</string>
<string name="location_permission_rationale_explore">Behörighet krävs för att visa en lista över bilder i närheten</string>
@ -365,11 +368,13 @@
<string name="delete">Radera</string>
<string name="Achievements">Prestationer</string>
<string name="Profile">Profil</string>
<string name="badges">Märken</string>
<string name="statistics">Statistik</string>
<string name="statistics_thanks">Mottagna tack</string>
<string name="statistics_featured">Utvalda bilder</string>
<string name="statistics_wikidata_edits">Bilder via \"Platser i närheten\"</string>
<string name="level" fuzzy="true">Nivå</string>
<string name="level">Nivå %d</string>
<string name="profileLevel">%s (Nivå %s)</string>
<string name="images_uploaded">Uppladdade bilder</string>
<string name="image_reverts">Bilder som inte har återställts</string>
<string name="images_used_by_wiki">Bilder som används</string>
@ -401,6 +406,7 @@
<string name="map_application_missing">Ingen kompatibel kartapp hittades på din enhet. Installera en kartapp för att använda denna funktion.</string>
<string name="title_page_bookmarks_pictures">Bilder</string>
<string name="title_page_bookmarks_locations">Platser</string>
<string name="title_page_bookmarks_categories">Kategorier</string>
<string name="menu_bookmark">Lägg till/ta bort bokmärke</string>
<string name="provider_bookmarks">Bokmärken</string>
<string name="bookmark_empty">Du har inte lagt till några bokmärken</string>
@ -802,4 +808,12 @@
<string name="red_pin">Det här platsen har ännu ingen bild. Gå och ta en!</string>
<string name="green_pin">Det här platsen har redan en bild.</string>
<string name="grey_pin">Kollar nu om den här platsen har en bild.</string>
<string name="error_while_loading">Fel med inladdning</string>
<string name="no_usages_found">Inga användningar hittades</string>
<string name="usages_on_commons_heading">Commons</string>
<string name="usages_on_other_wikis_heading">Andra wikier</string>
<string name="file_usages_container_heading">Filanvändning</string>
<string name="account">Konto</string>
<string name="caption">Bildtext</string>
<string name="caption_copied_to_clipboard">Bildtext kopierades till urklipp</string>
</resources>

View file

@ -154,6 +154,8 @@
<string name="menu_from_camera">拍照</string>
<string name="menu_nearby">附近</string>
<string name="provider_contributions">我的上传</string>
<string name="menu_copy_link">复制链接</string>
<string name="menu_link_copied">链接已复制到剪贴板。</string>
<string name="menu_share">分享</string>
<string name="menu_view_file_page">查看文件页面</string>
<string name="share_title_hint">说明(必需)</string>
@ -329,6 +331,7 @@
<string name="copy_wikicode">复制wikitext到剪贴板</string>
<string name="wikicode_copied">wikitext已经复制到剪贴板</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>
@ -412,11 +415,13 @@
<string name="delete">删除</string>
<string name="Achievements">成就</string>
<string name="Profile">个人资料</string>
<string name="badges">徽章</string>
<string name="statistics">统计</string>
<string name="statistics_thanks">已收到感谢</string>
<string name="statistics_featured">特色图片</string>
<string name="statistics_wikidata_edits">来自“附近地点”的图片</string>
<string name="level" fuzzy="true">等级</string>
<string name="level">等级%d</string>
<string name="profileLevel">%s%s級</string>
<string name="images_uploaded">已上传图片</string>
<string name="image_reverts">未还原图片</string>
<string name="images_used_by_wiki">使用过的图片</string>
@ -448,6 +453,7 @@
<string name="map_application_missing">您的设备上无法找到兼容的地图应用程序。请安装地图应用程序来使用此功能。</string>
<string name="title_page_bookmarks_pictures">图片</string>
<string name="title_page_bookmarks_locations">方位</string>
<string name="title_page_bookmarks_categories">分类</string>
<string name="menu_bookmark">添加/删除书签</string>
<string name="provider_bookmarks">书签</string>
<string name="bookmark_empty">您还未加入任何书签</string>
@ -850,4 +856,8 @@
<string name="red_pin">这个地点还没有照片,快去拍一张吧!</string>
<string name="green_pin">这个地点已经有照片了。</string>
<string name="grey_pin">现在检查这个地点是否有照片。</string>
<string name="error_while_loading">加载时出错</string>
<string name="usages_on_commons_heading">维基共享资源</string>
<string name="usages_on_other_wikis_heading">其它wiki</string>
<string name="file_usages_container_heading">文件用途</string>
</resources>

View file

@ -20,6 +20,7 @@
<dimen name="gigantic_gap">64dp</dimen>
<dimen name="standard_gap">16dp</dimen>
<dimen name="small_gap">8dp</dimen>
<dimen name="login_gap">20dp</dimen>
<dimen name="small_height">7dp</dimen>
<dimen name="tiny_gap">4dp</dimen>
<dimen name="very_tiny_gap">2dp</dimen>

View file

@ -860,5 +860,12 @@ Upload your first media by tapping on the add button.</string>
<string name="usages_on_other_wikis_heading">Other wikis</string>
<string name="bullet_point"></string>
<string name="file_usages_container_heading">File usages</string>
<string name="title_activity_single_web_view">SingleWebViewActivity</string>
<string name="account">Account</string>
<string name="vanish_account">Vanish Account</string>
<string name="account_vanish_request_confirm_title">Vanish account warning</string>
<string name="account_vanish_request_confirm"><![CDATA[Vanishing is a <b>last resort</b> and should <b>only be used when you wish to stop editing forever</b> and also to hide as many of your past associations as possible.<br/><br/>Account deletion on Wikimedia Commons is done by changing your account name to make it so others cannot recognize your contributions in a process called account vanishing. <b>Vanishing does not guarantee complete anonymity or remove contributions to the projects</b>.]]></string>
<string name="caption">Caption</string>
<string name="caption_copied_to_clipboard">Caption copied to clipboard</string>
</resources>

View file

@ -129,4 +129,14 @@
android:title="@string/send_log_file" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/account">
<Preference
android:key="vanishAccount"
app:singleLineTitle="false"
android:title="@string/vanish_account"/>
</PreferenceCategory>
</PreferenceScreen>

View file

@ -13,8 +13,8 @@ import com.nhaarman.mockitokotlin2.verify
import fr.free.nrw.commons.CameraPosition
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.kvstore.JsonKvStore
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 io.reactivex.android.plugins.RxAndroidPlugins
import io.reactivex.schedulers.Schedulers
import org.junit.Assert

View file

@ -306,7 +306,7 @@ class NearbyParentFragmentPresenterTest {
// 111.19 km real distance, return false if 148306.444306 > currentLocationSearchRadius
NearbyController.currentLocationSearchRadius = 148306.0
val isClose = nearbyPresenter.searchCloseToCurrentLocation()
assertFalse(isClose!!.equals(false))
assertFalse(!isClose)
}
/**
@ -318,7 +318,7 @@ class NearbyParentFragmentPresenterTest {
// 111.19 km real distance, return false if 148253.333 > currentLocationSearchRadius
NearbyController.currentLocationSearchRadius = 148307.0
val isClose = nearbyPresenter.searchCloseToCurrentLocation()
assertTrue(isClose!!)
assertTrue(isClose)
}
fun expectMapAndListUpdate() {

View file

@ -223,7 +223,7 @@ class SettingsFragmentUnitTests {
RecentLanguagesAdapter(
context,
listOf(Language("English", "en")),
hashMapOf<String, String>(),
mutableMapOf(),
),
)
val method: Method =

View file

@ -30,7 +30,7 @@ class LanguagesAdapterTest {
private lateinit var context: Context
@Mock
private lateinit var selectedLanguages: HashMap<Integer, String>
private lateinit var selectedLanguages: MutableMap<Int, String>
@Mock
private lateinit var parent: ViewGroup
@ -41,7 +41,7 @@ class LanguagesAdapterTest {
private lateinit var languagesAdapter: LanguagesAdapter
private lateinit var convertView: View
private var selectLanguages: HashMap<Integer, String> = HashMap()
private var selectLanguages: MutableMap<Int, String> = mutableMapOf()
@Before
@Throws(Exception::class)
@ -94,8 +94,8 @@ class LanguagesAdapterTest {
@Test
fun testSelectLanguageNotEmpty() {
selectLanguages[Integer(0)] = "es"
selectLanguages[Integer(1)] = "de"
selectLanguages[0] = "es"
selectLanguages[1] = "de"
languagesAdapter = LanguagesAdapter(context, selectLanguages)
Assertions.assertEquals(false, languagesAdapter.isEnabled(languagesAdapter.getIndexOfLanguageCode("es")))

View file

@ -246,7 +246,7 @@ class UploadMediaDetailAdapterUnitTest {
RecentLanguagesAdapter(
context,
listOf(Language("English", "en")),
hashMapOf<String, String>(),
mutableMapOf(),
),
)
val method: Method =

View file

@ -1,10 +1,12 @@
package fr.free.nrw.commons.upload
import android.net.Uri
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R
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.repository.UploadRepository
@ -24,6 +26,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.MockedStatic
import org.mockito.Mockito
@ -55,7 +58,7 @@ class UploadMediaPresenterTest {
private lateinit var place: Place
@Mock
private var location: LatLng? = null
private lateinit var location: LatLng
@Mock
private lateinit var uploadItem: UploadItem
@ -63,18 +66,12 @@ class UploadMediaPresenterTest {
@Mock
private lateinit var imageCoordinates: ImageCoordinates
@Mock
private lateinit var uploadMediaDetails: List<UploadMediaDetail>
private lateinit var testObservableUploadItem: Observable<UploadItem>
private lateinit var testSingleImageResult: Single<Int>
private lateinit var testScheduler: TestScheduler
private lateinit var mockedCountry: MockedStatic<Coordinates2Country>
@Mock
private lateinit var jsonKvStore: JsonKvStore
@Mock
lateinit var mockActivity: UploadActivity
@ -91,7 +88,6 @@ class UploadMediaPresenterTest {
uploadMediaPresenter =
UploadMediaPresenter(
repository,
jsonKvStore,
testScheduler,
testScheduler,
)
@ -120,10 +116,7 @@ class UploadMediaPresenterTest {
uploadMediaPresenter.receiveImage(uploadableFile, place, location)
verify(view).showProgress(true)
testScheduler.triggerActions()
verify(view).onImageProcessed(
ArgumentMatchers.any(UploadItem::class.java),
ArgumentMatchers.any(Place::class.java),
)
verify(view).onImageProcessed(isA())
}
/**
@ -167,7 +160,7 @@ class UploadMediaPresenterTest {
@Test
fun emptyFileNameTest() {
uploadMediaPresenter.handleCaptionResult(EMPTY_CAPTION, uploadItem)
verify(view).showMessage(ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())
verify(view).showMessage(R.string.add_caption_toast, R.color.color_error)
}
/**
@ -226,12 +219,11 @@ class UploadMediaPresenterTest {
@Test
fun fetchImageAndTitleTest() {
whenever(repository.getUploads()).thenReturn(listOf(uploadItem))
whenever(repository.getUploadItem(ArgumentMatchers.anyInt()))
.thenReturn(uploadItem)
whenever(repository.getUploadItem(ArgumentMatchers.anyInt())).thenReturn(uploadItem)
whenever(uploadItem.uploadMediaDetails).thenReturn(mutableListOf())
uploadMediaPresenter.fetchTitleAndDescription(0)
verify(view).updateMediaDetails(ArgumentMatchers.any())
verify(view).updateMediaDetails(isA())
}
/**
@ -273,12 +265,9 @@ class UploadMediaPresenterTest {
verify(view).showProgress(true)
testScheduler.triggerActions()
val captor: ArgumentCaptor<UploadItem> = ArgumentCaptor.forClass(UploadItem::class.java)
verify(view).onImageProcessed(
captor.capture(),
ArgumentMatchers.any(Place::class.java),
)
val captor = argumentCaptor<UploadItem>()
verify(view).onImageProcessed(captor.capture())
assertEquals("Exptected contry code", "de", captor.value.countryCode)
assertEquals("Exptected contry code", "de", captor.firstValue.countryCode)
}
}

View file

@ -140,6 +140,6 @@ class UploadModelUnitTest {
@Ignore
@Test
fun testSetSelectedExistingDepictions() {
uploadModel.selectedExistingDepictions = listOf("")
uploadModel.selectedExistingDepictions = mutableListOf("")
}
}

View file

@ -1,7 +1,9 @@
package fr.free.nrw.commons.upload
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.category.CategoriesModel
import fr.free.nrw.commons.category.CategoryItem
@ -17,6 +19,7 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
@ -118,7 +121,9 @@ class UploadRepositoryUnitTest {
@Test
fun testGetUploads() {
assertEquals(repository.getUploads(), uploadModel.uploads)
val result = listOf(uploadItem)
whenever(uploadModel.uploads).thenReturn(result)
assertSame(result, repository.getUploads())
}
@Test
@ -136,10 +141,10 @@ class UploadRepositoryUnitTest {
@Test
fun testSearchAll() {
assertEquals(
repository.searchAll("", listOf(), listOf()),
categoriesModel.searchAll("", listOf(), listOf()),
)
val empty = Observable.empty<List<CategoryItem>>()
whenever(categoriesModel.searchAll(any(), any(), any())).thenReturn(empty)
assertSame(empty, repository.searchAll("", listOf(), listOf()))
}
@Test
@ -164,7 +169,9 @@ class UploadRepositoryUnitTest {
@Test
fun testGetLicenses() {
assertEquals(repository.getLicenses(), uploadModel.licenses)
whenever(uploadModel.licenses).thenReturn(listOf())
repository.getLicenses()
verify(uploadModel).licenses
}
@Test
@ -208,10 +215,10 @@ class UploadRepositoryUnitTest {
@Test
fun testGetUploadItemCaseNonNull() {
`when`(uploadModel.items).thenReturn(listOf(uploadItem))
`when`(uploadModel.items).thenReturn(mutableListOf(uploadItem))
assertEquals(
repository.getUploadItem(0),
uploadModel.items[0],
uploadItem,
)
}
@ -220,19 +227,6 @@ class UploadRepositoryUnitTest {
assertEquals(repository.getUploadItem(-1), null)
}
@Test
fun testSetSelectedLicense() {
assertEquals(repository.setSelectedLicense(""), uploadModel.setSelectedLicense(""))
}
@Test
fun testSetSelectedExistingDepictions() {
assertEquals(
repository.setSelectedExistingDepictions(listOf("")),
uploadModel.setSelectedExistingDepictions(listOf("")),
)
}
@Test
fun testOnDepictItemClicked() {
assertEquals(
@ -243,12 +237,14 @@ class UploadRepositoryUnitTest {
@Test
fun testGetSelectedDepictions() {
assertEquals(repository.getSelectedDepictions(), uploadModel.selectedDepictions)
repository.getSelectedDepictions()
verify(uploadModel).selectedDepictions
}
@Test
fun testGetSelectedExistingDepictions() {
assertEquals(repository.getSelectedExistingDepictions(), uploadModel.selectedExistingDepictions)
repository.getSelectedExistingDepictions()
verify(uploadModel).selectedExistingDepictions
}
@Test
@ -324,8 +320,8 @@ class UploadRepositoryUnitTest {
@Test
fun testIsWMLSupportedForThisPlace() {
`when`(uploadModel.items).thenReturn(listOf(uploadItem))
`when`(uploadItem.isWLMUpload).thenReturn(true)
whenever(uploadModel.items).thenReturn(mutableListOf(uploadItem))
whenever(uploadItem.isWLMUpload).thenReturn(true)
assertEquals(
repository.isWMLSupportedForThisPlace(),
true,

View file

@ -34,7 +34,7 @@ import fr.free.nrw.commons.upload.ImageCoordinates
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadItem
import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM
import fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.Companion.LAST_ZOOM
import org.junit.Assert
import org.junit.Before
import org.junit.Test
@ -100,7 +100,7 @@ class UploadMediaDetailFragmentUnitTest {
private lateinit var place: Place
@Mock
private var location: fr.free.nrw.commons.location.LatLng? = null
private lateinit var location: LatLng
@Mock
private lateinit var defaultKvStore: JsonKvStore
@ -153,12 +153,6 @@ class UploadMediaDetailFragmentUnitTest {
Assert.assertNotNull(fragment)
}
@Test
@Throws(Exception::class)
fun testSetCallback() {
fragment.setCallback(null)
}
@Test
@Throws(Exception::class)
fun testOnCreate() {
@ -194,7 +188,7 @@ class UploadMediaDetailFragmentUnitTest {
Whitebox.setInternalState(fragment, "presenter", presenter)
val method: Method =
UploadMediaDetailFragment::class.java.getDeclaredMethod(
"init",
"initializeFragment",
)
method.isAccessible = true
method.invoke(fragment)
@ -209,7 +203,7 @@ class UploadMediaDetailFragmentUnitTest {
`when`(callback.totalNumberOfSteps).thenReturn(5)
val method: Method =
UploadMediaDetailFragment::class.java.getDeclaredMethod(
"init",
"initializeFragment",
)
method.isAccessible = true
method.invoke(fragment)
@ -229,22 +223,6 @@ class UploadMediaDetailFragmentUnitTest {
method.invoke(fragment, R.string.media_detail_step_title, R.string.media_details_tooltip)
}
@Test
@Throws(Exception::class)
fun testOnNextButtonClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onNextButtonClicked()
}
@Test
@Throws(Exception::class)
fun testOnPreviousButtonClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onPreviousButtonClicked()
}
@Test
@Throws(Exception::class)
fun testShowSimilarImageFragment() {
@ -258,7 +236,7 @@ class UploadMediaDetailFragmentUnitTest {
fun testOnImageProcessed() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(uploadItem.mediaUri).thenReturn(mediaUri)
fragment.onImageProcessed(uploadItem, place)
fragment.onImageProcessed(uploadItem)
}
@Test
@ -366,7 +344,10 @@ class UploadMediaDetailFragmentUnitTest {
`when`(uploadItem.gpsCoords).thenReturn(imageCoordinates)
val activityResult = ActivityResult(Activity.RESULT_OK, intent)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod("onCameraPosition", ActivityResult::class.java)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod(
"onCameraPosition",
ActivityResult::class.java
)
handleResultMethod.isAccessible = true
handleResultMethod.invoke(fragment, activityResult)
@ -382,7 +363,7 @@ class UploadMediaDetailFragmentUnitTest {
val cameraPosition = Mockito.mock(CameraPosition::class.java)
val latLng = Mockito.mock(LatLng::class.java)
Whitebox.setInternalState(fragment, "callback", callback)
Whitebox.setInternalState(fragment, "fragmentCallback", callback)
Whitebox.setInternalState(cameraPosition, "latitude", latLng.latitude)
Whitebox.setInternalState(cameraPosition, "longitude", latLng.longitude)
Whitebox.setInternalState(fragment, "editableUploadItem", uploadItem)
@ -394,9 +375,12 @@ class UploadMediaDetailFragmentUnitTest {
`when`(latLng.longitude).thenReturn(0.0)
`when`(uploadItem.gpsCoords).thenReturn(imageCoordinates)
val activityResult = ActivityResult(Activity.RESULT_OK,intent)
val activityResult = ActivityResult(Activity.RESULT_OK, intent)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod("onCameraPosition", ActivityResult::class.java)
val handleResultMethod = UploadMediaDetailFragment::class.java.getDeclaredMethod(
"onCameraPosition",
ActivityResult::class.java
)
handleResultMethod.isAccessible = true
handleResultMethod.invoke(fragment, activityResult)
@ -407,7 +391,7 @@ class UploadMediaDetailFragmentUnitTest {
@Throws(Exception::class)
fun testUpdateMediaDetails() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.updateMediaDetails(null)
fragment.updateMediaDetails(mock())
}
@Test
@ -417,21 +401,6 @@ class UploadMediaDetailFragmentUnitTest {
fragment.onDestroyView()
}
@Test
@Throws(Exception::class)
fun testOnLlContainerTitleClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.onLlContainerTitleClicked()
}
@Test
@Throws(Exception::class)
fun testOnIbMapClicked() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
Whitebox.setInternalState(fragment, "presenter", presenter)
fragment.onIbMapClicked()
}
@Test
@Throws(Exception::class)
fun testOnPrimaryCaptionTextChange() {

View file

@ -21,7 +21,7 @@ KOTLIN_VERSION=1.9.22
LEAK_CANARY_VERSION=2.10
DAGGER_VERSION=2.23
ROOM_VERSION=2.6.1
PREFERENCE_VERSION=1.1.0
PREFERENCE_VERSION=1.2.1
CORE_KTX_VERSION=1.9.0
ADAPTER_DELEGATES_VERSION=4.3.0
PAGING_VERSION=2.1.2