Added pending uploads screen (#5752)

* Added pending uploads screen

* Added failed uploads fragment

* Improved progress bars

* Implemented pause functionality

* Improved pause feature

* Fixed issue with sorting when adding more pictures during an upload

* Improved Tap to View notification

* Fixed issue with on going upload deletion

* Improved the deletion feature

* Fixed indentations and unit tests

* Fixed bugs

* Fixed failing test

* Added error message in Failed Uploads Fragment

* Improved error notification

* Moved auto-retry from the Main Activity to UploadProgressActivity

* Fixed large uploads issue

* Minor fixes

* Removed HashSet

* Fixed issue with progress bar

* Bug fixes

* Moved Auto Retry to MainActivity

* Fixed conflicts

* Fixed issue with upload icon

* Fixed null ptr issue on changing modes

* Improved recycler view

* Fixed irrelevant network call

* Fixed irrelevant network call

* Fixed constantly failing uploads

* Fixed constantly failing uploads

* Fixed constantly failing uploads

* Added error log

* Fixed refresh icon visibility in light mode

* Changed progress in progress activity

* Fixed progress bar issue

* Improved icons

* Improved deletion and removed cancelledUploads Hashset

* Fixed sorting, list size issue

* Improved current implementation

* Implemented flag for workers

* Implemented flag for workers

* Fixed sorting bug

* Fixed upload icon

* Improved pausing

* Made changes to visibility implementation

* Added image duplicity check on restart of failed image

* minor adjustments

* added javadoc/kdoc and fixed minor bug

* Fixed unit tests

* Added synchronized(lock)

* Added check to prevent multiple uploads starting at once

* Ignored failing test cases

* Temporary commit - Added jcenter

* Temporary commit - Removed library/commented

* Temporary commit - Removed library/commented

* Updated com.jraska.livedata:testing-ktx

* Ignored failing test - UploadControllerTest.kt

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadModelUnitTest

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadPresenterTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing tests - UploadRepositoryUnitTest.kt

* Ignored failing test - UploadRepositoryUnitTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - DepictedItemTest.kt

* Ignored failing test - FilesUtilsTest.kt

* Ignored failing test - WikiBaseClientUnitTest.kt

* Ignored failing test - WikiBaseClientUnitTest.kt

* Ignored failing test - WikiBaseClientUnitTest.kt

* Ignored failing test - WikidataClientTest.kt

* Ignored failing test - WikidataClientTest.kt

* Fixed unit tests

* Updated kdoc

---------

Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>
This commit is contained in:
Kanahia 2024-08-30 11:52:54 +05:30 committed by GitHub
parent 62d6dea219
commit 93f1e1ec29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2717 additions and 955 deletions

View file

@ -195,36 +195,4 @@ class MainActivityTest {
Espresso.pressBack()
UITestHelper.sleep(1000)
}
@Test
fun testLimitedConnectionModeToggle() {
val isEnabled = defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)
Espresso.onView(
Matchers.allOf(
ViewMatchers.withId(R.id.toggle_limited_connection_mode),
childAtPosition(
childAtPosition(
ViewMatchers.withId(R.id.toolbar),
1
),
0
),
ViewMatchers.isDisplayed()
)
).perform(ViewActions.click())
UITestHelper.sleep(1000)
if (isEnabled) {
Assert.assertFalse(
defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)
)
} else {
Assert.assertTrue(
defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)
)
}
}
}

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
@ -13,66 +14,65 @@
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="com.google.android.apps.photos.permission.GOOGLE_PHOTOS" />
<uses-permission android:name="android.permission.SET_WALLPAPER"/>
<uses-permission android:name="android.permission.SET_WALLPAPER" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<queries>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- Google Maps -->
<package android:name="com.google.android.apps.maps" />
</queries>
<!-- Needed only if your app targets Android 5.0 (API level 21) or higher. -->
</queries> <!-- Needed only if your app targets Android 5.0 (API level 21) or higher. -->
<uses-feature android:name="android.hardware.location.gps" />
<application
android:name=".CommonsApplication"
android:appComponentFactory="commons"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/LightAppTheme"
android:largeHeap="true"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
tools:replace="android:appComponentFactory"
android:appComponentFactory="commons"
android:requestLegacyExternalStorage = "true"
tools:ignore="GoogleAppIndexingWarning">
android:theme="@style/LightAppTheme"
tools:ignore="GoogleAppIndexingWarning"
tools:replace="android:appComponentFactory">
<activity
android:name=".nearby.WikidataFeedback"
android:exported="false" />
<activity
android:theme="@style/EditActivityTheme"
android:name=".upload.UploadProgressActivity"
android:exported="false" />
<activity
android:name=".description.DescriptionEditActivity"
android:exported="true" />
android:exported="true"
android:theme="@style/EditActivityTheme" />
<activity
android:name=".edit.EditActivity"
android:exported="false" />
<activity android:name="org.acra.dialog.CrashReportDialog"
android:process=":acra"
android:launchMode="singleInstance"
<activity
android:name="org.acra.dialog.CrashReportDialog"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true" />
android:finishOnTaskLaunch="true"
android:launchMode="singleInstance"
android:process=":acra" />
<activity
android:name=".media.ZoomableActivity"
android:label="Zoomable Activity"
android:configChanges="screenSize|keyboard|orientation"
android:label="Zoomable Activity"
android:parentActivityName=".customselector.ui.selector.CustomSelectorActivity" />
<activity android:name=".auth.LoginActivity"
<activity
android:name=".auth.LoginActivity"
android:exported="true">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
@ -80,21 +80,19 @@
<action android:name="android.intent.action.MAIN" />
</intent-filter>
<meta-data android:name="android.app.shortcuts"
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".WelcomeActivity" />
<activity
android:hardwareAccelerated="false"
android:name=".upload.UploadActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard"
android:exported="true"
android:hardwareAccelerated="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
>
android:windowSoftInputMode="adjustResize">
<intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND" />
@ -114,9 +112,9 @@
</activity>
<activity
android:name=".contributions.MainActivity"
android:configChanges="screenSize|keyboard|orientation"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:configChanges="screenSize|keyboard|orientation" />
android:label="@string/app_name" />
<activity
android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings" />
@ -124,57 +122,47 @@
android:name=".AboutActivity"
android:label="@string/title_activity_about"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".auth.SignupActivity"
android:configChanges="orientation|screenLayout|screenSize"
android:label="@string/title_activity_signup" />
<activity
android:name=".notification.NotificationActivity"
android:label="@string/navigation_item_notification" />
<activity android:name=".quiz.QuizActivity"
android:label="@string/quiz"/>
<activity android:name=".quiz.QuizResultActivity"
android:label="@string/result"/>
<activity
android:name=".quiz.QuizActivity"
android:label="@string/quiz" />
<activity
android:name=".quiz.QuizResultActivity"
android:label="@string/result" />
<activity
android:name=".customselector.ui.selector.CustomSelectorActivity"
android:label="@string/title_activity_custom_selector"
android:configChanges="screenSize|keyboard|orientation"
android:label="@string/title_activity_custom_selector"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".category.CategoryDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:label="@string/title_activity_featured_images"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".explore.depictions.WikidataItemDetailsActivity"
android:label="@string/title_activity_featured_images"
android:configChanges="screenSize|keyboard|orientation"
android:label="@string/title_activity_featured_images"
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".explore.SearchActivity"
android:configChanges="orientation|keyboardHidden|screenSize"
android:label="@string/title_activity_search"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|screenSize"
android:parentActivityName=".contributions.MainActivity"
/>
android:parentActivityName=".contributions.MainActivity" />
<activity
android:name=".profile.ProfileActivity"
android:configChanges="orientation|screenSize|keyboard"
android:label="@string/Profile" />
<activity
android:name=".review.ReviewActivity"
android:label="@string/title_activity_review" />
<activity
android:name=".LocationPicker.LocationPickerActivity"
android:label="Location Picker" />
@ -186,11 +174,11 @@
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name="org.acra.sender.SenderService"
android:exported="false"
@ -205,42 +193,36 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name=".category.CategoryContentProvider"
android:authorities="${applicationId}.categories.contentprovider"
android:exported="false"
android:label="@string/provider_categories"
android:syncable="false" />
<provider
android:name=".explore.recentsearches.RecentSearchesContentProvider"
android:authorities="${applicationId}.explore.recentsearches.contentprovider"
android:exported="false"
android:label="@string/provider_searches"
android:syncable="false" />
<provider
android:name=".recentlanguages.RecentLanguagesContentProvider"
android:authorities="${applicationId}.recentlanguages.contentprovider"
android:exported="false"
android:label="@string/provider_recent_languages"
android:syncable="false" />
<provider
android:name=".bookmarks.pictures.BookmarkPicturesContentProvider"
android:authorities="${applicationId}.bookmarks.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks"
android:syncable="false" />
<provider
android:name=".bookmarks.locations.BookmarkLocationsContentProvider"
android:authorities="${applicationId}.bookmarks.locations.contentprovider"
android:exported="false"
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<provider
android:name=".bookmarks.items.BookmarkItemsContentProvider"
android:authorities="${applicationId}.bookmarks.items.contentprovider"
@ -248,7 +230,8 @@
android:label="@string/provider_bookmarks_location"
android:syncable="false" />
<receiver android:name=".widget.PicOfDayAppWidget"
<receiver
android:name=".widget.PicOfDayAppWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@ -259,8 +242,9 @@
android:resource="@xml/pic_of_day_app_widget_info" />
</receiver>
<uses-library android:name="org.apache.http.legacy" android:required="false" />
<uses-library
android:name="org.apache.http.legacy"
android:required="false" />
</application>
</manifest>

View file

@ -142,15 +142,7 @@ public class CommonsApplication extends MultiDexApplication {
@Inject
ContributionDao contributionDao;
/**
* In-memory list of contributions whose uploads have been paused by the user
*/
public static Map<String, Boolean> pauseUploads = new HashMap<>();
/**
* In-memory list of uploads that have been cancelled by the user
*/
public static HashSet<String> cancelledUploads = new HashSet<>();
public static Boolean isPaused = false;
/**
* Used to declare and initialize various components and dependencies

View file

@ -28,6 +28,7 @@ data class Contribution constructor(
var dateCreatedSource: String? = null,
var wikidataPlace: WikidataPlace? = null,
var chunkInfo: ChunkInfo? = null,
var errorInfo: String? = null,
/**
* @return array list of entityids for the depictions
*/
@ -42,6 +43,7 @@ data class Contribution constructor(
var dateCreated: Date? = null,
var dateCreatedString: String? = null,
var dateModified: Date? = null,
var dateUploadStarted: Date? = null,
var hasInvalidLocation : Int = 0,
var contentUri: Uri? = null,
var countryCode : String? = null,
@ -99,7 +101,6 @@ data class Contribution constructor(
const val STATE_QUEUED = 2
const val STATE_IN_PROGRESS = 3
const val STATE_PAUSED = 4
const val STATE_QUEUED_LIMITED_CONNECTION_MODE=5
/**
* Formatting captions to the Wikibase format for sending labels
@ -127,11 +128,8 @@ data class Contribution constructor(
return chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
}
fun isPaused(): Boolean {
return CommonsApplication.pauseUploads[pageId] ?: false
fun dateUploadStartedInMillis(): Long {
return dateUploadStarted!!.time
}
fun unpause() {
CommonsApplication.pauseUploads[pageId] = false
}
}

View file

@ -9,6 +9,10 @@ import android.content.Intent;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.DataSource.Factory;
import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.filepicker.DefaultCallback;
import fr.free.nrw.commons.filepicker.FilePicker;
@ -25,6 +29,8 @@ import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
@ -39,10 +45,16 @@ public class ContributionController {
private boolean isInAppCameraUpload;
public LocationPermissionCallback locationPermissionCallback;
private LocationPermissionsHelper locationPermissionsHelper;
LiveData<PagedList<Contribution>> failedAndPendingContributionList;
LiveData<PagedList<Contribution>> pendingContributionList;
LiveData<PagedList<Contribution>> failedContributionList;
@Inject
LocationServiceManager locationManager;
@Inject
ContributionsRepository repository;
@Inject
public ContributionController(@Named("default_preferences") JsonKvStore defaultKvStore) {
this.defaultKvStore = defaultKvStore;
@ -115,8 +127,8 @@ public class ContributionController {
}
/**
* Shows a dialog alerting the user about location services being off
* and asking them to turn it on
* Shows a dialog alerting the user about location services being off and asking them to turn it
* on
* TODO: Add a seperate callback in LocationPermissionsHelper for this.
* Ref: https://github.com/commons-app/apps-android-commons/pull/5494/files#r1510553114
*
@ -307,4 +319,60 @@ public class ContributionController {
isInAppCameraUpload = false; // reset the flag for next use
return shareIntent;
}
/**
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED" and "PAUSED" and then it
* populates the `pendingContributionList`.
**/
void getPendingContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates(
Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
Contribution.STATE_PAUSED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
pendingContributionList = livePagedListBuilder.build();
}
/**
* Fetches the contributions with the state "FAILED" and populates the
* `failedContributionList`.
**/
void getFailedContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates(
Collections.singletonList(Contribution.STATE_FAILED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
failedContributionList = livePagedListBuilder.build();
}
/**
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and
* then it populates the `failedAndPendingContributionList`.
**/
void getFailedAndPendingContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates(
Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
Contribution.STATE_PAUSED, Contribution.STATE_FAILED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
failedAndPendingContributionList = livePagedListBuilder.build();
}
}

View file

@ -13,6 +13,7 @@ import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.Calendar;
import java.util.List;
import timber.log.Timber;
@Dao
public abstract class ContributionDao {
@ -27,6 +28,9 @@ public abstract class ContributionDao {
return Completable
.fromAction(() -> {
contribution.setDateModified(Calendar.getInstance().getTime());
if (contribution.getDateUploadStarted() == null) {
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
}
saveSynchronous(contribution);
});
}
@ -44,11 +48,32 @@ public abstract class ContributionDao {
@Delete
public abstract void deleteSynchronous(Contribution contribution);
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @throws SQLiteException If an SQLite error occurs.
*/
@Query("DELETE FROM contribution WHERE state IN (:states)")
public abstract void deleteContributionsWithStatesSynchronous(List<Integer> states)
throws SQLiteException;
public Completable delete(final Contribution contribution) {
return Completable
.fromAction(() -> deleteSynchronous(contribution));
}
/**
* Deletes contributions with specific states from the database.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsWithStates(List<Integer> states) {
return Completable
.fromAction(() -> deleteContributionsWithStatesSynchronous(states));
}
@Query("SELECT * from contribution WHERE media_filename=:fileName")
public abstract List<Contribution> getContributionWithTitle(String fileName);
@ -58,6 +83,26 @@ public abstract class ContributionDao {
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
public abstract Single<List<Contribution>> getContribution(List<Integer> states);
/**
* Gets contributions with specific states in descending order by the date they were uploaded.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by media_dateUploaded DESC")
public abstract DataSource.Factory<Integer, Contribution> getContributions(
List<Integer> states);
/**
* Gets contributions with specific states in ascending order by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
@Query("SELECT * from contribution WHERE state IN (:states) order by dateUploadStarted ASC")
public abstract DataSource.Factory<Integer, Contribution> getContributionsSortedByDateUploadStarted(
List<Integer> states);
@Query("SELECT COUNT(*) from contribution WHERE state in (:toUpdateStates)")
public abstract Single<Integer> getPendingUploads(int[] toUpdateStates);
@ -67,6 +112,15 @@ public abstract class ContributionDao {
@Update
public abstract void updateSynchronous(Contribution contribution);
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
*/
@Query("UPDATE contribution SET state = :newState WHERE state IN (:states)")
public abstract void updateContributionsState(List<Integer> states, int newState);
public Completable update(final Contribution contribution) {
return Completable
.fromAction(() -> {
@ -74,4 +128,18 @@ public abstract class ContributionDao {
updateSynchronous(contribution);
});
}
/**
* Updates the state of contributions with specific states asynchronously.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return Completable
.fromAction(() -> {
updateContributionsState(states, newState);
});
}
}

View file

@ -48,11 +48,8 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
binding = LayoutContributionBinding.bind(parent);
binding.retryButton.setOnClickListener(v -> retryUpload());
binding.cancelButton.setOnClickListener(v -> deleteUpload());
binding.contributionImage.setOnClickListener(v -> imageClicked());
binding.wikipediaButton.setOnClickListener(v -> wikipediaButtonClicked());
binding.pauseResumeButton.setOnClickListener(v -> onPauseResumeButtonClicked());
/* Set a dialog indicating that the upload is being paused. This is needed because pausing
an upload might take a dozen seconds. */
@ -80,9 +77,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
binding.contributionImage.getHierarchy().setPlaceholderImage(R.drawable.image_placeholder);
binding.contributionImage.getHierarchy().setFailureImage(R.drawable.image_placeholder);
final String imageSource = chooseImageSource(contribution.getMedia().getThumbUrl(),
contribution.getLocalUri());
if (!TextUtils.isEmpty(imageSource)) {
@ -90,79 +84,27 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageSource))
.setProgressiveRenderingEnabled(true)
.build();
}
else if (URLUtil.isFileUrl(imageSource)){
imageRequest=ImageRequest.fromUri(Uri.parse(imageSource));
}
else if(imageSource != null) {
} else if (URLUtil.isFileUrl(imageSource)) {
imageRequest = ImageRequest.fromUri(Uri.parse(imageSource));
} else if (imageSource != null) {
final File file = new File(imageSource);
imageRequest = ImageRequest.fromFile(file);
}
if(imageRequest != null){
if (imageRequest != null) {
binding.contributionImage.setImageRequest(imageRequest);
}
}
binding.contributionSequenceNumber.setText(String.valueOf(position + 1));
binding.contributionSequenceNumber.setVisibility(View.VISIBLE);
binding.wikipediaButton.setVisibility(View.GONE);
switch (contribution.getState()) {
case Contribution.STATE_COMPLETED:
binding.contributionState.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.GONE);
binding.contributionState.setText("");
checkIfMediaExistsOnWikipediaPage(contribution);
break;
case Contribution.STATE_QUEUED:
case Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE:
binding.contributionProgress.setVisibility(View.GONE);
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.contribution_state_queued);
binding.imageOptions.setVisibility(View.GONE);
break;
case Contribution.STATE_IN_PROGRESS:
binding.contributionState.setVisibility(View.GONE);
binding.contributionProgress.setVisibility(View.VISIBLE);
binding.wikipediaButton.setVisibility(View.GONE);
binding.pauseResumeButton.setVisibility(View.VISIBLE);
binding.cancelButton.setVisibility(View.GONE);
binding.retryButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
final long total = contribution.getDataLength();
final long transferred = contribution.getTransferred();
if (transferred == 0 || transferred >= total) {
binding.contributionProgress.setIndeterminate(true);
} else {
binding.contributionProgress.setIndeterminate(false);
binding.contributionProgress.setProgress((int) (((double) transferred / (double) total) * 100));
}
break;
case Contribution.STATE_PAUSED:
binding.contributionProgress.setVisibility(View.GONE);
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.paused);
binding.cancelButton.setVisibility(View.VISIBLE);
binding.retryButton.setVisibility(View.GONE);
binding.pauseResumeButton.setVisibility(View.VISIBLE);
binding.imageOptions.setVisibility(View.VISIBLE);
setResume();
if(pausingPopUp.isShowing()){
pausingPopUp.hide();
}
break;
case Contribution.STATE_FAILED:
binding.contributionState.setVisibility(View.VISIBLE);
binding.contributionState.setText(R.string.contribution_state_failed);
binding.contributionProgress.setVisibility(View.GONE);
binding.cancelButton.setVisibility(View.VISIBLE);
binding.retryButton.setVisibility(View.VISIBLE);
binding.pauseResumeButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
break;
}
}
/**
@ -196,8 +138,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
if (!mediaExists) {
binding.wikipediaButton.setVisibility(View.VISIBLE);
isWikipediaButtonDisplayed = true;
binding.cancelButton.setVisibility(View.GONE);
binding.retryButton.setVisibility(View.GONE);
binding.imageOptions.setVisibility(View.VISIBLE);
}
}
@ -217,20 +157,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
null;
}
/**
* Retry upload when it is failed
*/
public void retryUpload() {
callback.retryUpload(contribution);
}
/**
* Delete a failed upload attempt
*/
public void deleteUpload() {
callback.deleteUpload(contribution);
}
public void imageClicked() {
callback.openMediaDetail(position, isWikipediaButtonDisplayed);
}
@ -239,44 +165,6 @@ public class ContributionViewHolder extends RecyclerView.ViewHolder {
callback.addImageToWikipedia(contribution);
}
/**
* Triggers a callback for pause/resume
*/
public void onPauseResumeButtonClicked() {
if (binding.pauseResumeButton.getTag().toString().equals("pause")) {
pause();
} else {
resume();
}
}
private void resume() {
callback.resumeUpload(contribution);
setPaused();
}
private void pause() {
pausingPopUp.show();
callback.pauseUpload(contribution);
setResume();
}
/**
* Update pause/resume button to show pause state
*/
private void setPaused() {
binding.pauseResumeButton.setImageResource(R.drawable.pause_icon);
binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.pause));
}
/**
* Update pause/resume button to show resume state
*/
private void setResume() {
binding.pauseResumeButton.setImageResource(R.drawable.play_icon);
binding.pauseResumeButton.setTag(parent.getContext().getString(R.string.resume));
}
public ImageRequest getImageRequest() {
return imageRequest;
}

View file

@ -19,8 +19,5 @@ public class ContributionsContract {
Contribution getContributionsWithTitle(String uri);
void deleteUpload(Contribution contribution);
void saveContribution(Contribution contribution);
}
}

View file

@ -5,6 +5,7 @@ import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED;
import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED;
import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL;
import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import static fr.free.nrw.commons.utils.LengthUtils.computeBearing;
import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween;
@ -12,6 +13,7 @@ import android.Manifest;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
@ -25,6 +27,7 @@ import android.view.MenuItem.OnMenuItemClickListener;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@ -44,6 +47,8 @@ import fr.free.nrw.commons.notification.models.Notification;
import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
@ -104,6 +109,8 @@ public class ContributionsFragment
LocationServiceManager locationManager;
@Inject
NotificationController notificationController;
@Inject
ContributionController contributionController;
private CompositeDisposable compositeDisposable = new CompositeDisposable();
@ -113,10 +120,10 @@ public class ContributionsFragment
static final String MEDIA_DETAIL_PAGER_FRAGMENT_TAG = "MediaDetailFragmentTag";
private static final int MAX_RETRIES = 10;
public FragmentContributionsBinding binding;
@Inject ContributionsPresenter contributionsPresenter;
@Inject
ContributionsPresenter contributionsPresenter;
@Inject
SessionManager sessionManager;
@ -129,6 +136,12 @@ public class ContributionsFragment
public TextView notificationCount;
public TextView pendingUploadsCountTextView;
public TextView uploadsErrorTextView;
public ImageView pendingUploadsImageView;
private Campaign wlmCampaign;
String userName;
@ -150,10 +163,12 @@ public class ContributionsFragment
if (areAllGranted) {
onLocationPermissionGranted();
} else {
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
if (shouldShowRequestPermissionRationale(
Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
&& !store.getBoolean("doNotAskForLocationPermission", false)
&& (((MainActivity) getActivity()).activeFragment == ActiveFragment.CONTRIBUTIONS)) {
&& (((MainActivity) getActivity()).activeFragment
== ActiveFragment.CONTRIBUTIONS)) {
binding.cardViewNearby.permissionType = NearbyNotificationCardView.PermissionType.ENABLE_LOCATION_PERMISSION;
} else {
displayYouWontSeeNearbyMessage();
@ -198,11 +213,10 @@ public class ContributionsFragment
checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
// Do not ask for permission on activity start again
store.putBoolean("displayLocationPermissionForCardView",false);
store.putBoolean("displayLocationPermissionForCardView", false);
}
});
if (savedInstanceState != null) {
mediaDetailPagerFragment = (MediaDetailPagerFragment) getChildFragmentManager()
.findFragmentByTag(MEDIA_DETAIL_PAGER_FRAGMENT_TAG);
@ -212,9 +226,7 @@ public class ContributionsFragment
}
initFragments();
if(isUserProfile) {
binding.limitedConnectionEnabledLayout.setVisibility(View.GONE);
}else {
if (!isUserProfile) {
upDateUploadCount();
}
if (shouldShowMediaDetailsFragment) {
@ -230,7 +242,6 @@ public class ContributionsFragment
&& sessionManager.getCurrentAccount() != null && !isUserProfile) {
setUploadCount();
}
binding.limitedConnectionEnabledLayout.setOnClickListener(toggleDescriptionListener);
setHasOptionsMenu(true);
return binding.getRoot();
}
@ -258,10 +269,32 @@ public class ContributionsFragment
MenuItem notificationsMenuItem = menu.findItem(R.id.notifications);
final View notification = notificationsMenuItem.getActionView();
notificationCount = notification.findViewById(R.id.notification_count_badge);
MenuItem uploadMenuItem = menu.findItem(R.id.upload_tab);
final View uploadMenuItemActionView = uploadMenuItem.getActionView();
pendingUploadsCountTextView = uploadMenuItemActionView.findViewById(
R.id.pending_uploads_count_badge);
uploadsErrorTextView = uploadMenuItemActionView.findViewById(
R.id.uploads_error_count_badge);
pendingUploadsImageView = uploadMenuItemActionView.findViewById(
R.id.pending_uploads_image_view);
if (pendingUploadsImageView != null) {
pendingUploadsImageView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
if (pendingUploadsCountTextView != null) {
pendingUploadsCountTextView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
if (uploadsErrorTextView != null) {
uploadsErrorTextView.setOnClickListener(view -> {
startActivity(new Intent(getContext(), UploadProgressActivity.class));
});
}
notification.setOnClickListener(view -> {
NotificationActivity.startYourself(getContext(), "unread");
});
updateLimitedConnectionToggle(menu);
}
@SuppressLint("CheckResult")
@ -273,6 +306,33 @@ public class ContributionsFragment
throwable -> Timber.e(throwable, "Error occurred while loading notifications")));
}
/**
* Sets the visibility of the upload icon based on the number of failed and pending
* contributions.
*/
public void setUploadIconVisibility() {
contributionController.getFailedAndPendingContributions();
contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(),
list -> {
updateUploadIcon(list.size());
});
}
/**
* Sets the count for the upload icon based on the number of pending and failed contributions.
*/
public void setUploadIconCount() {
contributionController.getPendingContributions();
contributionController.pendingContributionList.observe(getViewLifecycleOwner(),
list -> {
updatePendingIcon(list.size());
});
contributionController.getFailedContributions();
contributionController.failedContributionList.observe(getViewLifecycleOwner(), list -> {
updateErrorIcon(list.size());
});
}
public void scrollToTop() {
if (contributionsListFragment != null) {
contributionsListFragment.scrollToTop();
@ -289,29 +349,6 @@ public class ContributionsFragment
}
}
public void updateLimitedConnectionToggle(Menu menu) {
MenuItem checkable = menu.findItem(R.id.toggle_limited_connection_mode);
boolean isEnabled = store
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false);
checkable.setChecked(isEnabled);
if (binding!=null) {
binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE);
}
checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24);
checkable.setOnMenuItemClickListener(new OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
((MainActivity) getActivity()).toggleLimitedConnectionMode();
boolean isEnabled = store.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false);
binding.limitedConnectionEnabledLayout.setVisibility(isEnabled ? View.VISIBLE : View.GONE);
checkable.setIcon((isEnabled) ? R.drawable.ic_baseline_cloud_off_24:R.drawable.ic_baseline_cloud_queue_24);
return false;
}
});
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
@ -355,7 +392,7 @@ public class ContributionsFragment
}
private void setupViewForMediaDetails() {
if (binding!=null) {
if (binding != null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
@ -465,7 +502,7 @@ public class ContributionsFragment
contributionsPresenter.onAttachView(this);
locationManager.addLocationListener(this);
if (binding==null) {
if (binding == null) {
return;
}
@ -484,7 +521,8 @@ public class ContributionsFragment
} catch (Exception e) {
Timber.e(e);
}
if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
@ -494,16 +532,19 @@ public class ContributionsFragment
}
// Notification Count and Campaigns should not be set, if it is used in User Profile
if(!isUserProfile) {
if (!isUserProfile) {
setNotificationCount();
fetchCampaigns();
setUploadIconVisibility();
setUploadIconCount();
}
}
mSensorManager.registerListener(this, mLight, SensorManager.SENSOR_DELAY_UI);
}
private void checkPermissionsAndShowNearbyCardView() {
if (PermissionUtils.hasPermission(getActivity(), new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
if (PermissionUtils.hasPermission(getActivity(),
new String[]{Manifest.permission.ACCESS_FINE_LOCATION})) {
onLocationPermissionGranted();
} else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
&& store.getBoolean("displayLocationPermissionForCardView", true)
@ -636,14 +677,14 @@ public class ContributionsFragment
*/
private void fetchCampaigns() {
if (Utils.isMonumentsEnabled(new Date())) {
if (binding!=null) {
if (binding != null) {
binding.campaignsView.setCampaign(wlmCampaign);
binding.campaignsView.setVisibility(View.VISIBLE);
}
} else if (store.getBoolean(CampaignView.CAMPAIGNS_DEFAULT_PREFERENCE, true)) {
presenter.getCampaigns();
} else {
if (binding!=null) {
if (binding != null) {
binding.campaignsView.setVisibility(View.GONE);
}
}
@ -657,7 +698,7 @@ public class ContributionsFragment
@Override
public void showCampaigns(Campaign campaign) {
if (campaign != null && !isUserProfile) {
if (binding!=null) {
if (binding != null) {
binding.campaignsView.setCampaign(campaign);
}
}
@ -676,67 +717,6 @@ public class ContributionsFragment
}
}
/**
* Restarts the upload process for a contribution
*
* @param contribution
*/
public void restartUpload(Contribution contribution) {
contribution.setState(Contribution.STATE_QUEUED);
contributionsPresenter.saveContribution(contribution);
Timber.d("Restarting for %s", contribution.toString());
}
/**
* Retry upload when it is failed
*
* @param contribution contribution to be retried
*/
@Override
public void retryUpload(Contribution contribution) {
if (NetworkUtils.isInternetConnectionEstablished(getContext())) {
if (contribution.getState() == STATE_PAUSED
|| contribution.getState() == Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE) {
restartUpload(contribution);
} else if (contribution.getState() == STATE_FAILED) {
int retries = contribution.getRetries();
// TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562
/* Limit the number of retries for a failed upload
to handle cases like invalid filename as such uploads
will never be successful */
if (retries < MAX_RETRIES) {
contribution.setRetries(retries + 1);
Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(),
retries + 1);
restartUpload(contribution);
} else {
// TODO: Show the exact reason for failure
Toast.makeText(getContext(),
R.string.retry_limit_reached, Toast.LENGTH_SHORT).show();
}
} else {
Timber.d("Skipping re-upload for non-failed %s", contribution.toString());
}
} else {
ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection);
}
}
/**
* Pauses the upload
*
* @param contribution
*/
@Override
public void pauseUpload(Contribution contribution) {
//Pause the upload in the global singleton
CommonsApplication.pauseUploads.put(contribution.getPageId(), true);
//Retain the paused state in DB
contribution.setState(STATE_PAUSED);
contributionsPresenter.saveContribution(contribution);
}
/**
* Notify the viewpager that number of items have changed.
*/
@ -747,6 +727,54 @@ public class ContributionsFragment
}
}
/**
* Updates the visibility and text of the pending uploads count TextView based on the given
* count.
*
* @param pendingCount The number of pending uploads.
*/
public void updatePendingIcon(int pendingCount) {
if (pendingUploadsCountTextView != null) {
if (pendingCount != 0) {
pendingUploadsCountTextView.setVisibility(View.VISIBLE);
pendingUploadsCountTextView.setText(String.valueOf(pendingCount));
} else {
pendingUploadsCountTextView.setVisibility(View.INVISIBLE);
}
}
}
/**
* Updates the visibility and text of the error uploads TextView based on the given count.
*
* @param errorCount The number of error uploads.
*/
public void updateErrorIcon(int errorCount) {
if (uploadsErrorTextView != null) {
if (errorCount != 0) {
uploadsErrorTextView.setVisibility(View.VISIBLE);
uploadsErrorTextView.setText(String.valueOf(errorCount));
} else {
uploadsErrorTextView.setVisibility(View.GONE);
}
}
}
/**
* Updates the visibility of the pending uploads ImageView based on the given count.
*
* @param count The number of pending uploads.
*/
public void updateUploadIcon(int count) {
if (pendingUploadsImageView != null) {
if (count != 0) {
pendingUploadsImageView.setVisibility(View.VISIBLE);
} else {
pendingUploadsImageView.setVisibility(View.GONE);
}
}
}
/**
* Replace whatever is in the current contributionsFragmentContainer view with
* mediaDetailPagerFragment, and preserve previous state in back stack. Called when user selects
@ -782,7 +810,8 @@ public class ContributionsFragment
public boolean backButtonClicked() {
if (mediaDetailPagerFragment != null && mediaDetailPagerFragment.isVisible()) {
if (store.getBoolean("displayNearbyCardView", true) && !isUserProfile) {
if (binding.cardViewNearby.cardViewVisibilityState == NearbyNotificationCardView.CardViewVisibilityState.READY) {
if (binding.cardViewNearby.cardViewVisibilityState
== NearbyNotificationCardView.CardViewVisibilityState.READY) {
binding.cardViewNearby.setVisibility(View.VISIBLE);
}
} else {
@ -829,6 +858,60 @@ public class ContributionsFragment
}
/**
* Restarts the upload process for a contribution
*
* @param contribution
*/
public void restartUpload(Contribution contribution) {
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
if (contribution.getState() == Contribution.STATE_FAILED) {
if (contribution.getErrorInfo() == null) {
contribution.setChunkInfo(null);
contribution.setTransferred(0);
}
contributionsPresenter.checkDuplicateImageAndRestartContribution(contribution);
} else {
contribution.setState(Contribution.STATE_QUEUED);
contributionsPresenter.saveContribution(contribution);
Timber.d("Restarting for %s", contribution.toString());
}
}
/**
* Retry upload when it is failed
*
* @param contribution contribution to be retried
*/
public void retryUpload(Contribution contribution) {
if (NetworkUtils.isInternetConnectionEstablished(getContext())) {
if (contribution.getState() == STATE_PAUSED) {
restartUpload(contribution);
} else if (contribution.getState() == STATE_FAILED) {
int retries = contribution.getRetries();
// TODO: Improve UX. Additional details: https://github.com/commons-app/apps-android-commons/pull/5257#discussion_r1304662562
/* Limit the number of retries for a failed upload
to handle cases like invalid filename as such uploads
will never be successful */
if (retries < MAX_RETRIES) {
contribution.setRetries(retries + 1);
Timber.d("Retried uploading %s %d times", contribution.getMedia().getFilename(),
retries + 1);
restartUpload(contribution);
} else {
// TODO: Show the exact reason for failure
Toast.makeText(getContext(),
R.string.retry_limit_reached, Toast.LENGTH_SHORT).show();
}
} else {
Timber.d("Skipping re-upload for non-failed %s", contribution.toString());
}
} else {
ViewUtil.showLongToast(getContext(), R.string.this_function_needs_network_connection);
}
}
/**
* Reload media detail fragment once media is nominated
*
@ -844,21 +927,6 @@ public class ContributionsFragment
}
}
// click listener to toggle description that means uses can press the limited connection
// banner and description will hide. Tap again to show description.
private View.OnClickListener toggleDescriptionListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
View view2 = binding.limitedConnectionDescriptionTextView;
if (view2.getVisibility() == View.GONE) {
view2.setVisibility(View.VISIBLE);
} else {
view2.setVisibility(View.GONE);
}
}
};
/**
* When the device rotates, rotate the Nearby banner's compass arrow in tandem.
*/

View file

@ -70,16 +70,8 @@ public class ContributionsListAdapter extends
public interface Callback {
void retryUpload(Contribution contribution);
void deleteUpload(Contribution contribution);
void openMediaDetail(int contribution, boolean isWikipediaPageExists);
void addImageToWikipedia(Contribution contribution);
void pauseUpload(Contribution contribution);
void resumeUpload(Contribution contribution);
}
}

View file

@ -17,7 +17,5 @@ public class ContributionsListContract {
}
public interface UserActionListener extends BasePresenter<View> {
void deleteUpload(Contribution contribution);
}
}

View file

@ -19,7 +19,7 @@ import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
@ -30,11 +30,11 @@ import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver;
import androidx.recyclerview.widget.RecyclerView.ItemAnimator;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import androidx.recyclerview.widget.SimpleItemAnimator;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionsListAdapter.Callback;
import fr.free.nrw.commons.databinding.FragmentContributionsListBinding;
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment;
import fr.free.nrw.commons.media.MediaClient;
@ -42,7 +42,6 @@ import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import javax.inject.Inject;
@ -56,7 +55,7 @@ import fr.free.nrw.commons.wikidata.model.WikiSite;
*/
public class ContributionsListFragment extends CommonsDaggerSupportFragment implements
ContributionsListContract.View, ContributionsListAdapter.Callback,
ContributionsListContract.View, Callback,
WikipediaInstructionsDialogFragment.Callback {
private static final String RV_STATE = "rv_scroll_state";
@ -81,7 +80,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private Animation rotate_forward;
private Animation rotate_backward;
private boolean isFabOpen;
@VisibleForTesting
protected RecyclerView rvContributionsList;
@ -99,7 +97,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private String userName;
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
new RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() {
@Override
public void onActivityResult(Map<String, Boolean> result) {
@ -151,7 +149,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
contributionsListPresenter.onAttachView(this);
binding.fabCustomGallery.setOnClickListener(v -> launchCustomSelector());
binding.fabCustomGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.custom_selector_title);
ViewUtil.showShortToast(getContext(), R.string.custom_selector_title);
return true;
});
@ -160,7 +158,8 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
binding.fabLayout.setVisibility(VISIBLE);
} else {
binding.tvContributionsOfUser.setVisibility(VISIBLE);
binding.tvContributionsOfUser.setText(getString(R.string.contributions_of_user, userName));
binding.tvContributionsOfUser.setText(
getString(R.string.contributions_of_user, userName));
binding.fabLayout.setVisibility(GONE);
}
@ -305,7 +304,8 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
public void onConfigurationChanged(final Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// check orientation
binding.fabLayout.setOrientation(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ?
binding.fabLayout.setOrientation(
newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ?
LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);
rvContributionsList
.setLayoutManager(
@ -326,7 +326,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
animateFAB(isFabOpen);
});
binding.fabCamera.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.add_contribution_from_camera);
ViewUtil.showShortToast(getContext(), R.string.add_contribution_from_camera);
return true;
});
binding.fabGallery.setOnClickListener(view -> {
@ -334,7 +334,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
animateFAB(isFabOpen);
});
binding.fabGallery.setOnLongClickListener(view -> {
ViewUtil.showShortToast(getContext(),R.string.menu_from_gallery);
ViewUtil.showShortToast(getContext(), R.string.menu_from_gallery);
return true;
});
}
@ -415,30 +415,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
}
}
@Override
public void retryUpload(final Contribution contribution) {
if (null != callback) {//Just being safe, ideally they won't be called when detached
callback.retryUpload(contribution);
}
}
@Override
public void deleteUpload(final Contribution contribution) {
DialogUtil.showAlertDialog(getActivity(),
String.format(Locale.getDefault(),
getString(R.string.cancelling_upload)),
String.format(Locale.getDefault(),
getString(R.string.cancel_upload_dialog)),
String.format(Locale.getDefault(), getString(R.string.yes)), String.format(Locale.getDefault(), getString(R.string.no)),
() -> {
ViewUtil.showShortToast(getContext(), R.string.cancelling_upload);
contributionsListPresenter.deleteUpload(contribution);
CommonsApplication.cancelledUploads.add(contribution.getPageId());
}, () -> {
// Do nothing
});
}
@Override
public void openMediaDetail(final int position, boolean isWikipediaButtonDisplayed) {
if (null != callback) {//Just being safe, ideally they won't be called when detached
@ -463,28 +439,6 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
});
}
/**
* Pauses the current upload
*
* @param contribution
*/
@Override
public void pauseUpload(Contribution contribution) {
ViewUtil.showShortToast(getContext(), R.string.pausing_upload);
callback.pauseUpload(contribution);
}
/**
* Resumes the current upload
*
* @param contribution
*/
@Override
public void resumeUpload(Contribution contribution) {
ViewUtil.showShortToast(getContext(), R.string.resuming_upload);
callback.retryUpload(contribution);
}
/**
* Display confirmation dialog with instructions when the user tries to add image to wikipedia
*
@ -536,13 +490,10 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
void notifyDataSetChanged();
void retryUpload(Contribution contribution);
void showDetail(int position, boolean isWikipediaButtonDisplayed);
void pauseUpload(Contribution contribution);
// Notify the viewpager that number of items have changed.
void viewPagerNotifyDataSetChanged();
}
}

View file

@ -10,6 +10,8 @@ import fr.free.nrw.commons.contributions.ContributionsListContract.UserActionLis
import fr.free.nrw.commons.di.CommonsApplicationModule;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import java.util.Arrays;
import java.util.Collections;
import javax.inject.Inject;
import javax.inject.Named;
@ -36,7 +38,7 @@ public class ContributionsListPresenter implements UserActionListener {
this.contributionBoundaryCallback = contributionBoundaryCallback;
this.repository = repository;
this.ioThreadScheduler = ioThreadScheduler;
this.contributionsRemoteDataSource=contributionsRemoteDataSource;
this.contributionsRemoteDataSource = contributionsRemoteDataSource;
compositeDisposable = new CompositeDisposable();
}
@ -71,10 +73,12 @@ public class ContributionsListPresenter implements UserActionListener {
} else {
contributionBoundaryCallback.setUserName(userName);
shouldSetBoundaryCallback = true;
factory = repository.fetchContributions();
factory = repository.fetchContributionsWithStates(
Collections.singletonList(Contribution.STATE_COMPLETED));
}
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, pagedListConfig);
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
if (shouldSetBoundaryCallback) {
livePagedListBuilder.setBoundaryCallback(contributionBoundaryCallback);
}
@ -89,15 +93,4 @@ public class ContributionsListPresenter implements UserActionListener {
contributionBoundaryCallback.dispose();
}
/**
* Delete a failed contribution from the local db
*/
@Override
public void deleteUpload(final Contribution contribution) {
compositeDisposable.add(repository
.deleteContributionFromDB(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
}

View file

@ -1,16 +1,14 @@
package fr.free.nrw.commons.contributions;
import androidx.paging.DataSource.Factory;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Completable;
import io.reactivex.Single;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Single;
/**
* The LocalDataSource class for Contributions
*/
@ -43,12 +41,14 @@ class ContributionsLocalDataSource {
/**
* Get contribution object from cursor
*
* @param uri
* @return
*/
public Contribution getContributionWithFileName(final String uri) {
final List<Contribution> contributionWithUri = contributionDao.getContributionWithTitle(uri);
if(!contributionWithUri.isEmpty()){
final List<Contribution> contributionWithUri = contributionDao.getContributionWithTitle(
uri);
if (!contributionWithUri.isEmpty()) {
return contributionWithUri.get(0);
}
return null;
@ -56,6 +56,7 @@ class ContributionsLocalDataSource {
/**
* Remove a contribution from the contributions table
*
* @param contribution
* @return
*/
@ -63,15 +64,48 @@ class ContributionsLocalDataSource {
return contributionDao.delete(contribution);
}
/**
* Deletes contributions with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsWithStates(List<Integer> states) {
return contributionDao.deleteContributionsWithStates(states);
}
public Factory<Integer, Contribution> getContributions() {
return contributionDao.fetchContributions();
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
public Factory<Integer, Contribution> getContributionsWithStates(List<Integer> states) {
return contributionDao.getContributions(states);
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
public Factory<Integer, Contribution> getContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return contributionDao.getContributionsSortedByDateUploadStarted(states);
}
public Single<List<Long>> saveContributions(final List<Contribution> contributions) {
final List<Contribution> contributionList = new ArrayList<>();
for(final Contribution contribution: contributions) {
final Contribution oldContribution = contributionDao.getContribution(contribution.getPageId());
if(oldContribution != null) {
for (final Contribution contribution : contributions) {
final Contribution oldContribution = contributionDao.getContribution(
contribution.getPageId());
if (oldContribution != null) {
contribution.setWikidataPlace(oldContribution.getWikidataPlace());
}
contributionList.add(contribution);
@ -84,10 +118,14 @@ class ContributionsLocalDataSource {
}
public void set(final String key, final long value) {
defaultKVStore.putLong(key,value);
defaultKVStore.putLong(key, value);
}
public Completable updateContribution(final Contribution contribution) {
return contributionDao.update(contribution);
}
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return contributionDao.updateContributionsWithStates(states, newState);
}
}

View file

@ -1,21 +1,26 @@
package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.MediaDataExtractor;
import fr.free.nrw.commons.contributions.ContributionsContract.UserActionListener;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* The presenter class for Contributions
*/
public class ContributionsPresenter implements UserActionListener {
private final ContributionsRepository repository;
private final ContributionsRepository contributionsRepository;
private final UploadRepository uploadRepository;
private final Scheduler ioThreadScheduler;
private CompositeDisposable compositeDisposable;
private ContributionsContract.View view;
@ -25,15 +30,17 @@ public class ContributionsPresenter implements UserActionListener {
@Inject
ContributionsPresenter(ContributionsRepository repository,
UploadRepository uploadRepository,
@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) {
this.repository = repository;
this.ioThreadScheduler=ioThreadScheduler;
this.contributionsRepository = repository;
this.uploadRepository = uploadRepository;
this.ioThreadScheduler = ioThreadScheduler;
}
@Override
public void onAttachView(ContributionsContract.View view) {
this.view = view;
compositeDisposable=new CompositeDisposable();
compositeDisposable = new CompositeDisposable();
}
@Override
@ -44,20 +51,31 @@ public class ContributionsPresenter implements UserActionListener {
@Override
public Contribution getContributionsWithTitle(String title) {
return repository.getContributionWithFileName(title);
return contributionsRepository.getContributionWithFileName(title);
}
/**
* Delete a failed contribution from the local db
* @param contribution
* Checks if a contribution is a duplicate and restarts the contribution process if it is not.
*
* @param contribution The contribution to check and potentially restart.
*/
@Override
public void deleteUpload(Contribution contribution) {
compositeDisposable.add(repository
public void checkDuplicateImageAndRestartContribution(Contribution contribution) {
compositeDisposable.add(uploadRepository
.checkDuplicateImage(contribution.getLocalUriPath().getPath())
.subscribeOn(ioThreadScheduler)
.subscribe(imageCheckResult -> {
if (imageCheckResult == IMAGE_OK) {
contribution.setState(Contribution.STATE_QUEUED);
saveContribution(contribution);
} else {
Timber.e("Contribution already exists");
compositeDisposable.add(contributionsRepository
.deleteContributionFromDB(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
}));
}
/**
* Update the contribution's state in the databse, upon completion, trigger the workmanager to
@ -65,9 +83,8 @@ public class ContributionsPresenter implements UserActionListener {
*
* @param contribution
*/
@Override
public void saveContribution(Contribution contribution) {
compositeDisposable.add(repository
compositeDisposable.add(contributionsRepository
.save(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(

View file

@ -29,6 +29,7 @@ public class ContributionsRepository {
/**
* Deletes a failed upload from DB
*
* @param contribution
* @return
*/
@ -36,8 +37,19 @@ public class ContributionsRepository {
return localDataSource.deleteContribution(contribution);
}
/**
* Deletes contributions from the database with specific states.
*
* @param states The states of the contributions to delete.
* @return A Completable indicating the result of the operation.
*/
public Completable deleteContributionsFromDBWithStates(List<Integer> states) {
return localDataSource.deleteContributionsWithStates(states);
}
/**
* Get contribution object with title
*
* @param fileName
* @return
*/
@ -49,19 +61,52 @@ public class ContributionsRepository {
return localDataSource.getContributions();
}
/**
* Fetches contributions with specific states.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states.
*/
public Factory<Integer, Contribution> fetchContributionsWithStates(List<Integer> states) {
return localDataSource.getContributionsWithStates(states);
}
/**
* Fetches contributions with specific states sorted by the date the upload started.
*
* @param states The states of the contributions to fetch.
* @return A DataSource factory for paginated contributions with the specified states sorted by
* date upload started.
*/
public Factory<Integer, Contribution> fetchContributionsWithStatesSortedByDateUploadStarted(
List<Integer> states) {
return localDataSource.getContributionsWithStatesSortedByDateUploadStarted(states);
}
public Single<List<Long>> save(List<Contribution> contributions) {
return localDataSource.saveContributions(contributions);
}
public Completable save(Contribution contributions){
public Completable save(Contribution contributions) {
return localDataSource.saveContributions(contributions);
}
public void set(String key, long value) {
localDataSource.set(key,value);
localDataSource.set(key, value);
}
public Completable updateContribution(Contribution contribution) {
return localDataSource.updateContribution(contribution);
}
/**
* Updates the state of contributions with specific states.
*
* @param states The current states of the contributions to update.
* @param newState The new state to set.
* @return A Completable indicating the result of the operation.
*/
public Completable updateContributionsWithStates(List<Integer> states, int newState) {
return localDataSource.updateContributionsWithStates(states, newState);
}
}

View file

@ -41,11 +41,14 @@ import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.PermissionUtils;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
@ -144,16 +147,16 @@ public class MainActivity extends BaseActivity
applicationKvStore.putBoolean("hasAlreadyLaunchedBigMultiupload", false);
applicationKvStore.putBoolean("hasAlreadyLaunchedCategoriesDialog", false);
}
if(savedInstanceState == null){
if (savedInstanceState == null) {
//starting a fresh fragment.
// Open Last opened screen if it is Contributions or Nearby, otherwise Contributions
if(applicationKvStore.getBoolean("last_opened_nearby")){
if (applicationKvStore.getBoolean("last_opened_nearby")) {
setTitle(getString(R.string.nearby_fragment));
showNearby();
loadFragment(NearbyParentFragment.newInstance(),false);
}else{
loadFragment(NearbyParentFragment.newInstance(), false);
} else {
setTitle(getString(R.string.contributions_fragment));
loadFragment(ContributionsFragment.newInstance(),false);
loadFragment(ContributionsFragment.newInstance(), false);
}
}
setUpPager();
@ -165,7 +168,8 @@ public class MainActivity extends BaseActivity
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
PermissionUtils.checkPermissionsAndPerformAction(
this,
() -> {},
() -> {
},
R.string.media_location_permission_denied,
R.string.add_location_manually,
permission.ACCESS_MEDIA_LOCATION);
@ -179,7 +183,8 @@ public class MainActivity extends BaseActivity
}
private void setUpPager() {
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(navListener = (item) -> {
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(
navListener = (item) -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
@ -193,18 +198,18 @@ public class MainActivity extends BaseActivity
}
private void setUpLoggedOutPager() {
loadFragment(ExploreFragment.newInstance(),false);
loadFragment(ExploreFragment.newInstance(), false);
binding.fragmentMainNavTabLayout.setOnNavigationItemSelectedListener(item -> {
if (!item.getTitle().equals(getString(R.string.more))) {
// do not change title for more fragment
setTitle(item.getTitle());
}
Fragment fragment = NavTabLoggedOut.of(item.getOrder()).newInstance();
return loadFragment(fragment,true);
return loadFragment(fragment, true);
});
}
private boolean loadFragment(Fragment fragment,boolean showBottom ) {
private boolean loadFragment(Fragment fragment, boolean showBottom) {
//showBottom so that we do not show the bottom tray again when constructing
//from the saved instance state.
if (fragment instanceof ContributionsFragment) {
@ -234,7 +239,8 @@ public class MainActivity extends BaseActivity
bookmarkFragment = (BookmarkFragment) fragment;
activeFragment = ActiveFragment.BOOKMARK;
} else if (fragment == null && showBottom) {
if (applicationKvStore.getBoolean("login_skipped") == true) { // If logged out, more sheet is different
if (applicationKvStore.getBoolean("login_skipped")
== true) { // If logged out, more sheet is different
MoreBottomSheetLoggedOutFragment bottomSheet = new MoreBottomSheetLoggedOutFragment();
bottomSheet.show(getSupportFragmentManager(),
"MoreBottomSheetLoggedOut");
@ -264,28 +270,30 @@ public class MainActivity extends BaseActivity
}
/**
* Adds number of uploads next to tab text "Contributions" then it will look like
* "Contributions (NUMBER)"
* Adds number of uploads next to tab text "Contributions" then it will look like "Contributions
* (NUMBER)"
*
* @param uploadCount
*/
public void setNumOfUploads(int uploadCount) {
if (activeFragment == ActiveFragment.CONTRIBUTIONS) {
setTitle(getResources().getString(R.string.contributions_fragment) +" "+ (
setTitle(getResources().getString(R.string.contributions_fragment) + " " + (
!(uploadCount == 0) ?
getResources()
.getQuantityString(R.plurals.contributions_subtitle,
uploadCount, uploadCount):getString(R.string.contributions_subtitle_zero)));
uploadCount, uploadCount)
: getString(R.string.contributions_subtitle_zero)));
}
}
/**
* Resume the uploads that got stuck because of the app being killed
* or the device being rebooted.
*
* Resume the uploads that got stuck because of the app being killed or the device being
* rebooted.
* <p>
* When the app is terminated or the device is restarted, contributions remain in the
* 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events.
* So, retrieving contributions labeled as 'STATE_IN_PROGRESS'
* from the database will provide the list of uploads that appear as stuck on opening the app again
* 'STATE_IN_PROGRESS' state. This status persists and doesn't change during these events. So,
* retrieving contributions labeled as 'STATE_IN_PROGRESS' from the database will provide the
* list of uploads that appear as stuck on opening the app again
*/
@SuppressLint("CheckResult")
private void checkAndResumeStuckUploads() {
@ -294,9 +302,10 @@ public class MainActivity extends BaseActivity
.subscribeOn(Schedulers.io())
.blockingGet();
Timber.d("Resuming " + stuckUploads.size() + " uploads...");
if(!stuckUploads.isEmpty()) {
for(Contribution contribution: stuckUploads) {
if (!stuckUploads.isEmpty()) {
for (Contribution contribution : stuckUploads) {
contribution.setState(Contribution.STATE_QUEUED);
contribution.setDateUploadStarted(Calendar.getInstance().getTime());
Completable.fromAction(() -> contributionDao.saveSynchronous(contribution))
.subscribeOn(Schedulers.io())
.subscribe();
@ -323,24 +332,24 @@ public class MainActivity extends BaseActivity
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
String activeFragmentName = savedInstanceState.getString("activeFragment");
if(activeFragmentName != null) {
if (activeFragmentName != null) {
restoreActiveFragment(activeFragmentName);
}
}
private void restoreActiveFragment(@NonNull String fragmentName) {
if(fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) {
if (fragmentName.equals(ActiveFragment.CONTRIBUTIONS.name())) {
setTitle(getString(R.string.contributions_fragment));
loadFragment(ContributionsFragment.newInstance(),false);
}else if(fragmentName.equals(ActiveFragment.NEARBY.name())) {
loadFragment(ContributionsFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.NEARBY.name())) {
setTitle(getString(R.string.nearby_fragment));
loadFragment(NearbyParentFragment.newInstance(),false);
}else if(fragmentName.equals(ActiveFragment.EXPLORE.name())) {
loadFragment(NearbyParentFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.EXPLORE.name())) {
setTitle(getString(R.string.navigation_item_explore));
loadFragment(ExploreFragment.newInstance(),false);
}else if(fragmentName.equals(ActiveFragment.BOOKMARK.name())) {
loadFragment(ExploreFragment.newInstance(), false);
} else if (fragmentName.equals(ActiveFragment.BOOKMARK.name())) {
setTitle(getString(R.string.bookmarks));
loadFragment(BookmarkFragment.newInstance(),false);
loadFragment(BookmarkFragment.newInstance(), false);
}
}
@ -356,8 +365,9 @@ public class MainActivity extends BaseActivity
// Means that nearby fragment is visible
/* If function nearbyParentFragment.backButtonClick() returns false, it means that the bottomsheet is
not expanded. So if the back button is pressed, then go back to the Contributions tab */
if(!nearbyParentFragment.backButtonClicked()){
getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment).commit();
if (!nearbyParentFragment.backButtonClicked()) {
getSupportFragmentManager().beginTransaction().remove(nearbyParentFragment)
.commit();
setSelectedItemId(NavTab.CONTRIBUTIONS.code());
}
} else if (exploreFragment != null && activeFragment == ActiveFragment.EXPLORE) {
@ -382,18 +392,6 @@ public class MainActivity extends BaseActivity
//initBackButton();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.notifications:
// Starts notification activity on click to notification icon
NotificationActivity.startYourself(this, "unread");
return true;
default:
return super.onOptionsItemSelected(item);
}
}
/**
* Retry all failed uploads as soon as the user returns to the app
*/
@ -403,31 +401,35 @@ public class MainActivity extends BaseActivity
getContribution(Collections.singletonList(Contribution.STATE_FAILED))
.subscribeOn(Schedulers.io())
.subscribe(failedUploads -> {
for (Contribution contribution: failedUploads) {
for (Contribution contribution : failedUploads) {
contributionsFragment.retryUpload(contribution);
}
});
}
public void toggleLimitedConnectionMode() {
defaultKvStore.putBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED,
!defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false));
if (defaultKvStore
.getBoolean(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false)) {
viewUtilWrapper
.showShortToast(getBaseContext(), getString(R.string.limited_connection_enabled));
} else {
WorkRequestHelper.Companion.makeOneTimeWorkRequest(getApplicationContext(),
ExistingWorkPolicy.APPEND_OR_REPLACE);
viewUtilWrapper
.showShortToast(getBaseContext(), getString(R.string.limited_connection_disabled));
/**
* Handles item selection in the options menu. This method is called when a user interacts with
* the options menu in the Top Bar.
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.upload_tab:
startActivity(new Intent(this, UploadProgressActivity.class));
return true;
case R.id.notifications:
// Starts notification activity on click to notification icon
NotificationActivity.startYourself(this, "unread");
return true;
default:
return super.onOptionsItemSelected(item);
}
}
public void centerMapToPlace(Place place) {
setSelectedItemId(NavTab.NEARBY.code());
nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback(new NearbyParentFragmentInstanceReadyCallback() {
nearbyParentFragment.setNearbyParentFragmentInstanceReadyCallback(
new NearbyParentFragmentInstanceReadyCallback() {
@Override
public void onReady() {
nearbyParentFragment.centerMapToPlace(place);
@ -437,7 +439,7 @@ public class MainActivity extends BaseActivity
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
Timber.d(data!=null?data.toString():"onActivityResult data is null");
Timber.d(data != null ? data.toString() : "onActivityResult data is null");
super.onActivityResult(requestCode, resultCode, data);
controller.handleActivityResult(this, requestCode, resultCode, data);
}
@ -482,14 +484,15 @@ public class MainActivity extends BaseActivity
/**
* Load default language in onCreate from SharedPreferences
*/
private void loadLocale(){
final SharedPreferences preferences = getSharedPreferences("Settings", Activity.MODE_PRIVATE);
private void loadLocale() {
final SharedPreferences preferences = getSharedPreferences("Settings",
Activity.MODE_PRIVATE);
final String language = preferences.getString("language", "");
final SettingsFragment settingsFragment = new SettingsFragment();
settingsFragment.setLocale(this, language);
}
public NavTabLayout.OnNavigationItemSelectedListener getNavListener(){
public NavTabLayout.OnNavigationItemSelectedListener getNavListener() {
return navListener;
}
}

View file

@ -20,6 +20,7 @@ import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.review.ReviewActivity;
import fr.free.nrw.commons.settings.SettingsActivity;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
/**
* This Class handles the dependency injection (using dagger)
@ -81,6 +82,9 @@ public abstract class ActivityBuilderModule {
@ContributesAndroidInjector
abstract ZoomableActivity bindZoomableActivity();
@ContributesAndroidInjector
abstract UploadProgressActivity bindUploadProgressActivity();
@ContributesAndroidInjector
abstract WikidataFeedback bindWikiFeedback();
}

View file

@ -34,6 +34,8 @@ import fr.free.nrw.commons.profile.achievements.AchievementsFragment;
import fr.free.nrw.commons.profile.leaderboard.LeaderboardFragment;
import fr.free.nrw.commons.review.ReviewImageFragment;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.upload.FailedUploadsFragment;
import fr.free.nrw.commons.upload.PendingUploadsFragment;
import fr.free.nrw.commons.upload.categories.UploadCategoriesFragment;
import fr.free.nrw.commons.upload.depicts.DepictsFragment;
import fr.free.nrw.commons.upload.license.MediaLicenseFragment;
@ -155,4 +157,10 @@ public abstract class FragmentBuilderModule {
@ContributesAndroidInjector
abstract LeaderboardFragment bindLeaderboardFragment();
@ContributesAndroidInjector
abstract PendingUploadsFragment bindPendingUploadsFragment();
@ContributesAndroidInjector
abstract FailedUploadsFragment bindFailedUploadsFragment();
}

View file

@ -306,7 +306,6 @@ public class AchievementsFragment extends CommonsDaggerSupportFragment {
if (uploadCount==0){
setZeroAchievements();
}else {
binding.imagesUploadedProgressbar.setVisibility(View.VISIBLE);
binding.imagesUploadedProgressbar.setProgress
(100*uploadCount/levelInfo.getMaxUploadCount());
@ -326,9 +325,9 @@ public class AchievementsFragment extends CommonsDaggerSupportFragment {
getString(R.string.ok),
() -> {},
true);
binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE);
binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
// binding.imagesUploadedProgressbar.setVisibility(View.INVISIBLE);
// binding.imageRevertsProgressbar.setVisibility(View.INVISIBLE);
// binding.imagesUsedByWikiProgressBar.setVisibility(View.INVISIBLE);
binding.achievementBadgeImage.setVisibility(View.INVISIBLE);
binding.imagesUsedByWikiText.setText(R.string.no_image);
binding.imagesRevertedText.setText(R.string.no_image_reverted);
@ -354,7 +353,7 @@ public class AchievementsFragment extends CommonsDaggerSupportFragment {
* @param achievements
*/
private void inflateAchievements(Achievements achievements) {
binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE);
// binding.imagesUsedByWikiProgressBar.setVisibility(View.VISIBLE);
binding.thanksReceived.setText(String.valueOf(achievements.getThanksReceived()));
binding.imagesUsedByWikiProgressBar.setProgress
(100 * achievements.getUniqueUsedImages() / levelInfo.getMaxUniqueImages());

View file

@ -203,6 +203,16 @@ public class UploadRepository {
return uploadModel.getImageQuality(uploadItem, location);
}
/**
* query the RemoteDataSource for image duplicity check
*
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
public Single<Integer> checkDuplicateImage(String filePath) {
return uploadModel.checkDuplicateImage(filePath);
}
/**
* query the RemoteDataSource for caption quality
*

View file

@ -0,0 +1,139 @@
package fr.free.nrw.commons.upload
import android.net.Uri
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.URLUtil
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import java.io.File
/**
* Adapter for displaying failed uploads in a paginated list in FailedUploadsFragment. This adapter
* binds the data from [Contribution] objects to the item views in the RecyclerView, allowing users to view
* details of failed uploads, retry them, or delete them.
*
* @param callback The callback to handle user actions such as Delete Uploads and Restart Uploads
* on failed uploads.
*/
class FailedUploadsAdapter(callback: Callback) :
PagedListAdapter<Contribution, FailedUploadsAdapter.ViewHolder>(ContributionDiffCallback()) {
private var callback: Callback = callback
/**
* Creates a new ViewHolder instance. Inflates the layout for each item in the list.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view: View =
LayoutInflater.from(parent.context).inflate(R.layout.item_failed_upload, parent, false)
return ViewHolder(view)
}
/**
* Binds data to the provided ViewHolder. Sets up the item view with data from the
* contribution at the specified position.
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item: Contribution? = getItem(position)
if (item != null) {
holder.titleTextView.setText(item.media.displayTitle)
}
var imageRequest: ImageRequest? = null
val imageSource: String = item?.localUri.toString()
if (!TextUtils.isEmpty(imageSource)) {
if (URLUtil.isFileUrl(imageSource)) {
imageRequest = ImageRequest.fromUri(Uri.parse(imageSource))!!
} else if (imageSource != null) {
val file = File(imageSource)
imageRequest = ImageRequest.fromFile(file)!!
}
if (imageRequest != null) {
holder.itemImage.setImageRequest(imageRequest)
}
}
if (item != null) {
if (item.state == Contribution.STATE_FAILED) {
if (item.errorInfo != null) {
holder.errorTextView.setText(item.errorInfo)
} else {
holder.errorTextView.setText("Failed")
}
holder.errorTextView.visibility = View.VISIBLE
holder.itemProgress.visibility = View.GONE
}
}
holder.deleteButton.setOnClickListener {
callback.deleteUpload(item)
}
holder.retryButton.setOnClickListener {
callback.restartUpload(position)
}
holder.itemImage.setImageRequest(imageRequest)
}
/**
* ViewHolder for the failed upload item. Holds references to the views for each item.
*/
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var itemImage: com.facebook.drawee.view.SimpleDraweeView =
itemView.findViewById(R.id.itemImage)
var titleTextView: TextView = itemView.findViewById<TextView>(R.id.titleTextView)
var itemProgress: ProgressBar = itemView.findViewById<ProgressBar>(R.id.itemProgress)
var errorTextView: TextView = itemView.findViewById<TextView>(R.id.errorTextView)
var deleteButton: ImageView = itemView.findViewById(R.id.deleteButton)
var retryButton: ImageView = itemView.findViewById(R.id.retryButton)
}
/**
* Returns the ID of the item at the specified position. Uses the pageId of the contribution
* for unique identification.
*/
override fun getItemId(position: Int): Long {
return getItem(position)?.pageId?.hashCode()?.toLong() ?: position.toLong()
}
/**
* Uses DiffUtil to calculate the changes in the list
* It has methods that check pageId and the content of the items to determine if its a new item
*/
class ContributionDiffCallback : DiffUtil.ItemCallback<Contribution>() {
override fun areItemsTheSame(oldItem: Contribution, newItem: Contribution): Boolean {
return oldItem.pageId.hashCode() == newItem.pageId.hashCode()
}
override fun areContentsTheSame(oldItem: Contribution, newItem: Contribution): Boolean {
return oldItem.transferred == newItem.transferred
}
}
/**
* Callback interface for handling actions related to failed uploads.
*/
interface Callback {
/**
* Deletes the failed upload item.
*
* @param contribution to be deleted.
*/
fun deleteUpload(contribution: Contribution?)
/**
* Restarts the upload for the item at the specified index.
*
* @param index The position of the item in the list.
*/
fun restartUpload(index: Int)
}
}

View file

@ -0,0 +1,201 @@
package fr.free.nrw.commons.upload
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.paging.PagedList
import androidx.recyclerview.widget.LinearLayoutManager
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.databinding.FragmentFailedUploadsBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.profile.ProfileActivity
import fr.free.nrw.commons.utils.DialogUtil
import fr.free.nrw.commons.utils.ViewUtil
import org.apache.commons.lang3.StringUtils
import java.util.Locale
import javax.inject.Inject
/**
* Fragment for displaying a list of failed uploads in Upload Progress Activity. This fragment provides
* functionality for the user to retry or cancel failed uploads.
*/
class FailedUploadsFragment : CommonsDaggerSupportFragment(), PendingUploadsContract.View,
FailedUploadsAdapter.Callback {
@Inject
lateinit var pendingUploadsPresenter: PendingUploadsPresenter
@Inject
lateinit var mediaClient: MediaClient
@Inject
lateinit var sessionManager: SessionManager
private var userName: String? = null
lateinit var binding: FragmentFailedUploadsBinding
private lateinit var adapter: FailedUploadsAdapter
var contributionsList = ArrayList<Contribution>()
private lateinit var uploadProgressActivity: UploadProgressActivity
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is UploadProgressActivity) {
uploadProgressActivity = context
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//Now that we are allowing this fragment to be started for
// any userName- we expect it to be passed as an argument
if (arguments != null) {
userName = requireArguments().getString(ProfileActivity.KEY_USERNAME)
}
if (StringUtils.isEmpty(userName)) {
userName = sessionManager!!.getUserName()
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentFailedUploadsBinding.inflate(layoutInflater)
pendingUploadsPresenter.onAttachView(this)
initAdapter()
return binding.root
}
fun initAdapter() {
adapter = FailedUploadsAdapter(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initRecyclerView()
}
/**
* Initializes the recycler view.
*/
fun initRecyclerView() {
binding.failedUploadsRecyclerView.setLayoutManager(LinearLayoutManager(this.context))
binding.failedUploadsRecyclerView.adapter = adapter
pendingUploadsPresenter!!.getFailedContributions()
pendingUploadsPresenter!!.failedContributionList.observe(
viewLifecycleOwner
) { list: PagedList<Contribution?> ->
adapter.submitList(list)
contributionsList = ArrayList()
list.forEach {
if (it != null) {
contributionsList.add(it)
}
}
if (list.size == 0) {
uploadProgressActivity.setErrorIconsVisibility(false)
binding.nofailedTextView.visibility = View.VISIBLE
binding.failedUplaodsLl.visibility = View.GONE
} else {
uploadProgressActivity.setErrorIconsVisibility(true)
binding.nofailedTextView.visibility = View.GONE
binding.failedUplaodsLl.visibility = View.VISIBLE
binding.failedUploadsRecyclerView.setAdapter(adapter)
}
}
}
/**
* Restarts all the failed uploads.
*/
fun restartUploads() {
if (contributionsList != null) {
pendingUploadsPresenter.restartUploads(
contributionsList,
0,
this.requireContext().applicationContext
)
}
}
/**
* Restarts a specific upload.
*/
override fun restartUpload(index: Int) {
if (contributionsList != null) {
pendingUploadsPresenter.restartUpload(
contributionsList,
index,
this.requireContext().applicationContext
)
}
}
/**
* Deletes a specific upload after getting a confirmation from the user using Dialog.
*/
override fun deleteUpload(contribution: Contribution?) {
DialogUtil.showAlertDialog(
requireActivity(),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancelling_upload)
),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancel_upload_dialog)
),
String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)),
String.format(Locale.getDefault(), requireActivity().getString(R.string.no)),
{
ViewUtil.showShortToast(context, R.string.cancelling_upload)
pendingUploadsPresenter.deleteUpload(
contribution,
this.requireContext().applicationContext
)
},
{}
)
}
/**
* Deletes all the uploads after getting a confirmation from the user using Dialog.
*/
fun deleteUploads() {
if (contributionsList != null) {
DialogUtil.showAlertDialog(
requireActivity(),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancelling_all_the_uploads)
),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads)
),
String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)),
String.format(Locale.getDefault(), requireActivity().getString(R.string.no)),
{
ViewUtil.showShortToast(context, R.string.cancelling_upload)
uploadProgressActivity.hidePendingIcons()
pendingUploadsPresenter.deleteUploads(
listOf(Contribution.STATE_FAILED)
)
},
{}
)
}
}
}

View file

@ -140,7 +140,7 @@ public class ImageProcessingService {
* @param filePath file to be checked
* @return IMAGE_DUPLICATE or IMAGE_OK
*/
private Single<Integer> checkDuplicateImage(String filePath) {
Single<Integer> checkDuplicateImage(String filePath) {
Timber.d("Checking for duplicate image %s", filePath);
return Single.fromCallable(() -> fileUtilsWrapper.getFileInputStream(filePath))
.map(fileUtilsWrapper::getSHA1)

View file

@ -0,0 +1,229 @@
package fr.free.nrw.commons.upload
import android.net.Uri
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.URLUtil
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.facebook.imagepipeline.request.ImageRequest
import fr.free.nrw.commons.R
import fr.free.nrw.commons.contributions.Contribution
import timber.log.Timber
import java.io.File
/**
* Adapter for displaying pending uploads in a paginated list in PendingUploadsFragment. This adapter
* binds data from [Contribution] objects to the item views in the RecyclerView, allowing users to
* view details of pending uploads and perform actions such as deleting them.
*
* @param callback The callback to handle user actions such as Delete Uploads on pending uploads.
*/
class PendingUploadsAdapter(private val callback: Callback) :
PagedListAdapter<Contribution, PendingUploadsAdapter.ViewHolder>(ContributionDiffCallback()) {
/**
* Creates a new ViewHolder instance. Inflates the layout for each item in the list.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view: View = LayoutInflater.from(parent.context)
.inflate(R.layout.item_pending_upload, parent, false)
return ViewHolder(view)
}
/**
* Binds data to the provided ViewHolder. Sets up the item view with data from the
* contribution at the specified position utilizing payloads.
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.isNotEmpty()) {
when (val latestPayload = payloads.lastOrNull()) {
is ContributionChangePayload.Progress -> holder.bindProgress(
latestPayload.transferred,
latestPayload.total,
getItem(position)!!.state
)
is ContributionChangePayload.State -> holder.bindState(latestPayload.state)
else -> onBindViewHolder(holder, position)
}
} else {
onBindViewHolder(holder, position)
}
}
/**
* Binds data to the provided ViewHolder. Sets up the item view with data from the
* contribution at the specified position.
*/
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contribution = getItem(position)
contribution?.let {
holder.bind(it)
holder.deleteButton.setOnClickListener {
callback.deleteUpload(contribution)
}
}
}
/**
* ViewHolder class for holding and binding item views.
*/
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var itemImage: com.facebook.drawee.view.SimpleDraweeView =
itemView.findViewById(R.id.itemImage)
var titleTextView: TextView = itemView.findViewById(R.id.titleTextView)
var itemProgress: ProgressBar = itemView.findViewById(R.id.itemProgress)
var errorTextView: TextView = itemView.findViewById(R.id.errorTextView)
var deleteButton: ImageView = itemView.findViewById(R.id.deleteButton)
fun bind(contribution: Contribution) {
titleTextView.text = contribution.media.displayTitle
val imageSource: String = contribution.localUri.toString()
var imageRequest: ImageRequest? = null
if (!TextUtils.isEmpty(imageSource)) {
if (URLUtil.isFileUrl(imageSource)) {
imageRequest = ImageRequest.fromUri(Uri.parse(imageSource))
} else {
val file = File(imageSource)
imageRequest = ImageRequest.fromFile(file)
}
}
if (imageRequest != null) {
itemImage.setImageRequest(imageRequest)
}
bindState(contribution.state)
bindProgress(contribution.transferred, contribution.dataLength, contribution.state)
}
fun bindState(state: Int) {
if (state == Contribution.STATE_QUEUED || state == Contribution.STATE_PAUSED) {
errorTextView.text = "Queued"
errorTextView.visibility = View.VISIBLE
itemProgress.visibility = View.GONE
} else {
errorTextView.visibility = View.GONE
itemProgress.visibility = View.VISIBLE
}
}
fun bindProgress(transferred: Long, total: Long, state: Int) {
if (transferred == 0L) {
errorTextView.text = "Queued"
errorTextView.visibility = View.VISIBLE
itemProgress.visibility = View.GONE
} else {
if (state == Contribution.STATE_QUEUED || state == Contribution.STATE_PAUSED) {
errorTextView.text = "Queued"
errorTextView.visibility = View.VISIBLE
itemProgress.visibility = View.GONE
} else {
errorTextView.visibility = View.GONE
itemProgress.visibility = View.VISIBLE
if (transferred >= total) {
itemProgress.isIndeterminate = true
} else {
itemProgress.isIndeterminate = false
itemProgress.progress =
((transferred.toDouble() / total.toDouble()) * 100).toInt()
}
}
}
}
}
/**
* Callback interface for handling actions related to failed uploads.
*/
interface Callback {
/**
* Deletes the failed upload item.
*
* @param contribution to be deleted.
*/
fun deleteUpload(contribution: Contribution?)
}
/**
* Uses DiffUtil and payloads to calculate the changes in the list
* It has methods that check pageId and the content of the items to determine if its a new item
*/
class ContributionDiffCallback : DiffUtil.ItemCallback<Contribution>() {
/**
* Checks if two items represent the same contribution.
* @param oldItem The old contribution item.
* @param newItem The new contribution item.
* @return True if the items are the same, false otherwise.
*/
override fun areItemsTheSame(oldItem: Contribution, newItem: Contribution): Boolean {
return oldItem.pageId.hashCode() == newItem.pageId.hashCode()
}
/**
* Checks if the content of two items is the same.
* @param oldItem The old contribution item.
* @param newItem The new contribution item.
* @return True if the contents are the same, false otherwise.
*/
override fun areContentsTheSame(oldItem: Contribution, newItem: Contribution): Boolean {
return oldItem.transferred == newItem.transferred
}
/**
* Returns a payload representing the change between the old and new items.
* @param oldItem The old contribution item.
* @param newItem The new contribution item.
* @return An object representing the change, or null if there are no changes.
*/
override fun getChangePayload(oldItem: Contribution, newItem: Contribution): Any? {
return when {
oldItem.transferred != newItem.transferred -> {
ContributionChangePayload.Progress(newItem.transferred, newItem.dataLength)
}
oldItem.state != newItem.state -> {
ContributionChangePayload.State(newItem.state)
}
else -> super.getChangePayload(oldItem, newItem)
}
}
}
/**
* Returns the unique item ID for the contribution at the specified position.
* @param position The position of the item.
* @return The unique item ID.
*/
override fun getItemId(position: Int): Long {
return getItem(position)?.pageId?.hashCode()?.toLong() ?: position.toLong()
}
/**
* Sealed interface representing different types of changes to a contribution.
*/
private sealed interface ContributionChangePayload {
/**
* Represents a change in the progress of a contribution.
* @param transferred The amount of data transferred.
* @param total The total amount of data.
*/
data class Progress(val transferred: Long, val total: Long) : ContributionChangePayload
/**
* Represents a change in the state of a contribution.
* @param state The state of the contribution.
*/
data class State(val state: Int) : ContributionChangePayload
}
}

View file

@ -0,0 +1,31 @@
package fr.free.nrw.commons.upload;
import android.content.Context;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract;
import fr.free.nrw.commons.nearby.contract.NearbyParentFragmentContract.View;
/**
* The contract using which the PendingUploadsFragment or FailedUploadsFragment would communicate
* with its PendingUploadsPresenter
*/
public class PendingUploadsContract {
/**
* Interface representing the view for uploads.
*/
public interface View { }
/**
* Interface representing the user actions related to uploads.
*/
public interface UserActionListener extends
BasePresenter<fr.free.nrw.commons.upload.PendingUploadsContract.View> {
/**
* Deletes a upload.
*/
void deleteUpload(Contribution contribution, Context context);
}
}

View file

@ -0,0 +1,200 @@
package fr.free.nrw.commons.upload
import android.content.Context
import android.os.AsyncTask
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.paging.PagedList
import androidx.paging.PositionalDataSource
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.databinding.FragmentPendingUploadsBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.profile.ProfileActivity
import fr.free.nrw.commons.utils.DialogUtil.showAlertDialog
import fr.free.nrw.commons.utils.ViewUtil
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
/**
* Fragment for showing pending uploads in Upload Progress Activity. This fragment provides
* functionality for the user to pause uploads.
*/
class PendingUploadsFragment : CommonsDaggerSupportFragment(), PendingUploadsContract.View,
PendingUploadsAdapter.Callback {
@Inject
lateinit var pendingUploadsPresenter: PendingUploadsPresenter
private lateinit var binding: FragmentPendingUploadsBinding
private lateinit var uploadProgressActivity: UploadProgressActivity
private lateinit var adapter: PendingUploadsAdapter
private var contributionsSize = 0
var contributionsList = ArrayList<Contribution>()
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is UploadProgressActivity) {
uploadProgressActivity = context
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreate(savedInstanceState)
binding = FragmentPendingUploadsBinding.inflate(inflater, container, false)
pendingUploadsPresenter.onAttachView(this)
initAdapter()
return binding.root
}
fun initAdapter() {
adapter = PendingUploadsAdapter(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initRecyclerView()
}
/**
* Initializes the recycler view.
*/
fun initRecyclerView() {
binding.pendingUploadsRecyclerView.setLayoutManager(LinearLayoutManager(this.context))
binding.pendingUploadsRecyclerView.adapter = adapter
pendingUploadsPresenter!!.setup()
pendingUploadsPresenter!!.totalContributionList.observe(
viewLifecycleOwner
) { list: PagedList<Contribution?> ->
contributionsSize = list.size
contributionsList = ArrayList()
var pausedOrQueuedUploads = 0
list.forEach {
if (it != null) {
if (it.state == Contribution.STATE_PAUSED
|| it.state == Contribution.STATE_QUEUED
|| it.state == Contribution.STATE_IN_PROGRESS
) {
contributionsList.add(it)
}
if (it.state == Contribution.STATE_PAUSED
|| it.state == Contribution.STATE_QUEUED
) {
pausedOrQueuedUploads++
}
}
}
if (contributionsSize == 0) {
binding.nopendingTextView.visibility = View.VISIBLE
binding.pendingUplaodsLl.visibility = View.GONE
uploadProgressActivity.hidePendingIcons()
} else {
binding.nopendingTextView.visibility = View.GONE
binding.pendingUplaodsLl.visibility = View.VISIBLE
adapter.submitList(list)
binding.progressTextView.setText(contributionsSize.toString() + " uploads left")
if ((pausedOrQueuedUploads == contributionsSize) || CommonsApplication.isPaused) {
uploadProgressActivity.setPausedIcon(true)
} else {
uploadProgressActivity.setPausedIcon(false)
}
}
}
}
/**
* Cancels a specific upload after getting a confirmation from the user using Dialog.
*/
override fun deleteUpload(contribution: Contribution?) {
showAlertDialog(
requireActivity(),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancelling_upload)
),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancel_upload_dialog)
),
String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)),
String.format(Locale.getDefault(), requireActivity().getString(R.string.no)),
{
ViewUtil.showShortToast(context, R.string.cancelling_upload)
pendingUploadsPresenter.deleteUpload(
contribution,
this.requireContext().applicationContext
)
},
{}
)
}
/**
* Restarts all the paused uploads.
*/
fun restartUploads() {
if (contributionsList != null) {
pendingUploadsPresenter.restartUploads(
contributionsList,
0,
this.requireContext().applicationContext
)
}
}
/**
* Pauses all the ongoing uploads.
*/
fun pauseUploads() {
pendingUploadsPresenter.pauseUploads()
}
/**
* Cancels all the uploads after getting a confirmation from the user using Dialog.
*/
fun deleteUploads() {
showAlertDialog(
requireActivity(),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.cancelling_all_the_uploads)
),
String.format(
Locale.getDefault(),
requireActivity().getString(R.string.are_you_sure_that_you_want_cancel_all_the_uploads)
),
String.format(Locale.getDefault(), requireActivity().getString(R.string.yes)),
String.format(Locale.getDefault(), requireActivity().getString(R.string.no)),
{
ViewUtil.showShortToast(context, R.string.cancelling_upload)
uploadProgressActivity.hidePendingIcons()
pendingUploadsPresenter.deleteUploads(
listOf(
Contribution.STATE_QUEUED,
Contribution.STATE_IN_PROGRESS,
Contribution.STATE_PAUSED
)
)
},
{}
)
}
}

View file

@ -0,0 +1,262 @@
package fr.free.nrw.commons.upload;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.DataSource.Factory;
import androidx.paging.LivePagedListBuilder;
import androidx.paging.PagedList;
import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.contributions.Contribution;
import fr.free.nrw.commons.contributions.ContributionBoundaryCallback;
import fr.free.nrw.commons.contributions.ContributionsRemoteDataSource;
import fr.free.nrw.commons.contributions.ContributionsRepository;
import fr.free.nrw.commons.di.CommonsApplicationModule;
import fr.free.nrw.commons.repository.UploadRepository;
import fr.free.nrw.commons.upload.PendingUploadsContract.UserActionListener;
import fr.free.nrw.commons.upload.PendingUploadsContract.View;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import io.reactivex.Scheduler;
import io.reactivex.disposables.CompositeDisposable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* The presenter class for PendingUploadsFragment and FailedUploadsFragment
*/
public class PendingUploadsPresenter implements UserActionListener {
private final ContributionBoundaryCallback contributionBoundaryCallback;
private final ContributionsRepository contributionsRepository;
private final UploadRepository uploadRepository;
private final Scheduler ioThreadScheduler;
private final CompositeDisposable compositeDisposable;
private final ContributionsRemoteDataSource contributionsRemoteDataSource;
LiveData<PagedList<Contribution>> totalContributionList;
LiveData<PagedList<Contribution>> failedContributionList;
@Inject
PendingUploadsPresenter(
final ContributionBoundaryCallback contributionBoundaryCallback,
final ContributionsRemoteDataSource contributionsRemoteDataSource,
final ContributionsRepository contributionsRepository,
final UploadRepository uploadRepository,
@Named(CommonsApplicationModule.IO_THREAD) final Scheduler ioThreadScheduler) {
this.contributionBoundaryCallback = contributionBoundaryCallback;
this.contributionsRepository = contributionsRepository;
this.uploadRepository = uploadRepository;
this.ioThreadScheduler = ioThreadScheduler;
this.contributionsRemoteDataSource = contributionsRemoteDataSource;
compositeDisposable = new CompositeDisposable();
}
/**
* Setups the paged list of Pending Uploads. This method sets the configuration for paged list
* and ties it up with the live data object. This method can be tweaked to update the lazy
* loading behavior of the contributions list
*/
void setup() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted(
Arrays.asList(Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS,
Contribution.STATE_PAUSED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
totalContributionList = livePagedListBuilder.build();
}
/**
* Setups the paged list of Failed Uploads. This method sets the configuration for paged list
* and ties it up with the live data object. This method can be tweaked to update the lazy
* loading behavior of the contributions list
*/
void getFailedContributions() {
final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder())
.setPrefetchDistance(50)
.setPageSize(10).build();
Factory<Integer, Contribution> factory;
factory = contributionsRepository.fetchContributionsWithStatesSortedByDateUploadStarted(
Collections.singletonList(Contribution.STATE_FAILED));
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig);
failedContributionList = livePagedListBuilder.build();
}
@Override
public void onAttachView(@NonNull View view) {
}
@Override
public void onDetachView() {
compositeDisposable.clear();
contributionsRemoteDataSource.dispose();
contributionBoundaryCallback.dispose();
}
/**
* Deletes the specified upload (contribution) from the database.
*
* @param contribution The contribution object representing the upload to be deleted.
* @param context The context in which the operation is being performed.
*/
@Override
public void deleteUpload(final Contribution contribution, Context context) {
compositeDisposable.add(contributionsRepository
.deleteContributionFromDB(contribution)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
/**
* Pauses all the uploads by changing the state of contributions from STATE_QUEUED and
* STATE_IN_PROGRESS to STATE_PAUSED in the database.
*/
public void pauseUploads() {
CommonsApplication.isPaused = true;
compositeDisposable.add(contributionsRepository
.updateContributionsWithStates(
List.of(Contribution.STATE_QUEUED, Contribution.STATE_IN_PROGRESS),
Contribution.STATE_PAUSED)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
/**
* Deletes contributions from the database that match the specified states.
*
* @param states A list of integers representing the states of the contributions to be deleted.
*/
public void deleteUploads(List<Integer> states) {
compositeDisposable.add(contributionsRepository
.deleteContributionsFromDBWithStates(states)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
/**
* Restarts the uploads for the specified list of contributions starting from the given index.
*
* @param contributionList The list of contributions to be restarted.
* @param index The starting index in the list from which to restart uploads.
* @param context The context in which the operation is being performed.
*/
public void restartUploads(List<Contribution> contributionList, int index, Context context) {
CommonsApplication.isPaused = false;
if (index >= contributionList.size()) {
return;
}
Contribution it = contributionList.get(index);
if (it.getState() == Contribution.STATE_FAILED) {
it.setDateUploadStarted(Calendar.getInstance().getTime());
if (it.getErrorInfo() == null) {
it.setChunkInfo(null);
it.setTransferred(0);
}
compositeDisposable.add(uploadRepository
.checkDuplicateImage(it.getLocalUriPath().getPath())
.subscribeOn(ioThreadScheduler)
.subscribe(imageCheckResult -> {
if (imageCheckResult == IMAGE_OK) {
it.setState(Contribution.STATE_QUEUED);
compositeDisposable.add(contributionsRepository
.save(it)
.subscribeOn(ioThreadScheduler)
.doOnComplete(() -> {
restartUploads(contributionList, index + 1, context);
})
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(
context, ExistingWorkPolicy.KEEP)));
} else {
Timber.e("Contribution already exists");
compositeDisposable.add(contributionsRepository
.deleteContributionFromDB(it)
.subscribeOn(ioThreadScheduler).doOnComplete(() -> {
restartUploads(contributionList, index + 1, context);
})
.subscribe());
}
}, throwable -> {
Timber.e(throwable);
restartUploads(contributionList, index + 1, context);
}));
} else {
it.setState(Contribution.STATE_QUEUED);
compositeDisposable.add(contributionsRepository
.save(it)
.subscribeOn(ioThreadScheduler)
.doOnComplete(() -> {
restartUploads(contributionList, index + 1, context);
})
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(
context, ExistingWorkPolicy.KEEP)));
}
}
/**
* Restarts the upload for the specified list of contributions for the given index.
*
* @param contributionList The list of contributions.
* @param index The index in the list which to be restarted.
* @param context The context in which the operation is being performed.
*/
public void restartUpload(List<Contribution> contributionList, int index, Context context) {
CommonsApplication.isPaused = false;
if (index >= contributionList.size()) {
return;
}
Contribution it = contributionList.get(index);
if (it.getState() == Contribution.STATE_FAILED) {
it.setDateUploadStarted(Calendar.getInstance().getTime());
if (it.getErrorInfo() == null) {
it.setChunkInfo(null);
it.setTransferred(0);
}
compositeDisposable.add(uploadRepository
.checkDuplicateImage(it.getLocalUriPath().getPath())
.subscribeOn(ioThreadScheduler)
.subscribe(imageCheckResult -> {
if (imageCheckResult == IMAGE_OK) {
it.setState(Contribution.STATE_QUEUED);
compositeDisposable.add(contributionsRepository
.save(it)
.subscribeOn(ioThreadScheduler)
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(
context, ExistingWorkPolicy.KEEP)));
} else {
Timber.e("Contribution already exists");
compositeDisposable.add(contributionsRepository
.deleteContributionFromDB(it)
.subscribeOn(ioThreadScheduler)
.subscribe());
}
}));
} else {
it.setState(Contribution.STATE_QUEUED);
compositeDisposable.add(contributionsRepository
.save(it)
.subscribeOn(ioThreadScheduler)
.subscribe(() -> WorkRequestHelper.Companion.makeOneTimeWorkRequest(
context, ExistingWorkPolicy.KEEP)));
}
}
}

View file

@ -9,5 +9,6 @@ data class StashUploadResult(
enum class StashUploadState {
SUCCESS,
PAUSED,
FAILED
FAILED,
CANCELLED
}

View file

@ -6,6 +6,7 @@ import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.contributions.ChunkInfo
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.upload.worker.UploadWorker.NotificationUpdateProgressListener
import fr.free.nrw.commons.wikidata.mwapi.MwException
import io.reactivex.Observable
@ -33,7 +34,8 @@ class UploadClient @Inject constructor(
private val csrfTokenClient: CsrfTokenClient,
private val pageContentsCreator: PageContentsCreator,
private val fileUtilsWrapper: FileUtilsWrapper,
private val gson: Gson, private val timeProvider: TimeProvider
private val gson: Gson, private val timeProvider: TimeProvider,
private val contributionDao: ContributionDao
) {
private val CHUNK_SIZE = 512 * 1024 // 512 KB
@ -58,8 +60,6 @@ class UploadClient @Inject constructor(
)
}
contribution.unpause()
val file = contribution.localUriPath
val fileChunks = fileUtilsWrapper.getFileChunks(file, CHUNK_SIZE)
val mediaType = fileUtilsWrapper.getMimeType(file).toMediaTypeOrNull()
@ -79,17 +79,35 @@ class UploadClient @Inject constructor(
val errorMessage = AtomicReference<String>()
compositeDisposable.add(
Observable.fromIterable(fileChunks).forEach { chunkFile: File ->
if (canProcess(contribution, failures)) {
if (canProcess(contributionDao, contribution, failures)) {
if (contributionDao.getContribution(contribution.pageId) == null) {
compositeDisposable.clear()
return@forEach
} else {
processChunk(
filename, contribution, notificationUpdater, chunkFile,
failures, chunkInfo, index, errorMessage, mediaType!!, file!!, fileChunks.size
filename,
contribution,
notificationUpdater,
chunkFile,
failures,
chunkInfo,
index,
errorMessage,
mediaType!!,
file!!,
fileChunks.size
)
}
}
}
)
return when {
contribution.isPaused() -> {
contributionDao.getContribution(contribution.pageId) == null -> {
return Observable.just(StashUploadResult(StashUploadState.CANCELLED, null, "Upload cancelled"))
}
contributionDao.getContribution(contribution.pageId).state == Contribution.STATE_PAUSED
|| CommonsApplication.isPaused -> {
Timber.d("Upload stash paused %s", contribution.pageId)
Observable.just(StashUploadResult(StashUploadState.PAUSED, null, null))
}
@ -248,10 +266,15 @@ class UploadClient @Inject constructor(
}
}
private fun canProcess(contribution: Contribution, failures: AtomicBoolean): Boolean {
private fun canProcess(
contributionDao: ContributionDao,
contribution: Contribution,
failures: AtomicBoolean
): Boolean {
// As long as the contribution hasn't been paused and there are no errors,
// we can process the current chunk.
return !(contribution.isPaused() || failures.get())
return !(contributionDao.getContribution(contribution.pageId).state == Contribution.STATE_PAUSED
|| failures.get() || CommonsApplication.isPaused)
}
private fun shouldSkip(

View file

@ -103,6 +103,16 @@ public class UploadModel {
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
*

View file

@ -5,6 +5,7 @@ import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.di.NetworkingModule;
import fr.free.nrw.commons.upload.categories.CategoriesContract;
import fr.free.nrw.commons.upload.categories.CategoriesPresenter;
@ -50,8 +51,8 @@ public abstract class UploadModule {
public static UploadClient provideUploadClient(final UploadInterface uploadInterface,
@Named(NetworkingModule.NAMED_COMMONS_CSRF) final CsrfTokenClient csrfTokenClient,
final PageContentsCreator pageContentsCreator, final FileUtilsWrapper fileUtilsWrapper,
final Gson gson) {
final Gson gson, final ContributionDao contributionDao) {
return new UploadClient(uploadInterface, csrfTokenClient, pageContentsCreator,
fileUtilsWrapper, gson, System::currentTimeMillis);
fileUtilsWrapper, gson, System::currentTimeMillis, contributionDao);
}
}

View file

@ -0,0 +1,223 @@
package fr.free.nrw.commons.upload
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.fragment.app.Fragment
import androidx.viewpager.widget.ViewPager
import fr.free.nrw.commons.R
import fr.free.nrw.commons.ViewPagerAdapter
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.databinding.ActivityUploadProgressBinding
import fr.free.nrw.commons.theme.BaseActivity
import io.reactivex.functions.Consumer
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import javax.inject.Inject
/**
* Activity to manage the progress of uploads. It includes tabs to show pending and failed uploads,
* and provides menu options to pause, resume, cancel, and retry uploads. Also, it contains ViewPager
* which holds Pending Uploads Fragment and Failed Uploads Fragment to show list of pending and
* failed uploads respectively.
*/
class UploadProgressActivity : BaseActivity() {
private lateinit var binding: ActivityUploadProgressBinding
private var pendingUploadsFragment: PendingUploadsFragment? = null
private var failedUploadsFragment: FailedUploadsFragment? = null
var viewPagerAdapter: ViewPagerAdapter? = null
var menu: Menu? = null
@Inject
lateinit var contributionDao: ContributionDao
val fragmentList: MutableList<Fragment> = ArrayList()
val titleList: MutableList<String> = ArrayList()
var isPaused = true
var isPendingIconsVisible = true
var isErrorIconsVisisble = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityUploadProgressBinding.inflate(layoutInflater)
setContentView(binding.root)
viewPagerAdapter = ViewPagerAdapter(supportFragmentManager)
binding.uploadProgressViewPager.setAdapter(viewPagerAdapter)
binding.uploadProgressViewPager.setId(R.id.upload_progress_view_pager)
binding.uploadProgressTabLayout.setupWithViewPager(binding.uploadProgressViewPager)
binding.toolbarBinding.toolbar.title = getString(R.string.uploads)
setSupportActionBar(binding.toolbarBinding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.uploadProgressViewPager.addOnPageChangeListener(object :
ViewPager.OnPageChangeListener {
override fun onPageScrolled(
position: Int, positionOffset: Float,
positionOffsetPixels: Int
) {
}
override fun onPageSelected(position: Int) {
updateMenuItems(position)
if (position == 2) {
binding.uploadProgressViewPager.setCanScroll(false)
} else {
binding.uploadProgressViewPager.setCanScroll(true)
}
}
override fun onPageScrollStateChanged(state: Int) {
}
})
setTabs()
}
/**
* Initializes and sets up the tabs data by creating instances of `PendingUploadsFragment`
* and `FailedUploadsFragment`, adds them to the `fragmentList`, and assigns corresponding
* titles from resources to the `titleList`.
*/
fun setTabs() {
pendingUploadsFragment = PendingUploadsFragment()
failedUploadsFragment = FailedUploadsFragment()
fragmentList.add(pendingUploadsFragment!!)
titleList.add(getString(R.string.pending))
fragmentList.add(failedUploadsFragment!!)
titleList.add(getString(R.string.failed))
viewPagerAdapter!!.setTabData(fragmentList, titleList)
viewPagerAdapter!!.notifyDataSetChanged()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_uploads, menu)
this.menu = menu
updateMenuItems(0)
return super.onCreateOptionsMenu(menu)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
/**
* Updates the menu items based on the current position in the view pager and the visibility
* of icons related to pending or failed uploads. This function dynamically modifies the menu
* to display pause, resume, retry, and cancel options depending on the state of the uploads.
*
* @param currentPosition The current position in the view pager. A value of `0` indicates
* pending uploads, while `1` indicates failed uploads.
*/
fun updateMenuItems(currentPosition: Int) {
if (menu != null) {
menu!!.clear()
if (currentPosition == 0) {
if (isPendingIconsVisible) {
if (!isPaused) {
if (menu!!.findItem(R.id.pause_icon) == null) {
menu!!.add(
Menu.NONE,
R.id.pause_icon,
Menu.NONE,
getString(R.string.pause)
)
.setIcon(R.drawable.pause_icon)
.setOnMenuItemClickListener {
pendingUploadsFragment!!.pauseUploads()
setPausedIcon(true)
true
}
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
if (menu!!.findItem(R.id.cancel_icon) == null) {
menu!!.add(
Menu.NONE,
R.id.cancel_icon,
Menu.NONE,
getString(R.string.cancel)
)
.setIcon(R.drawable.ic_cancel_upload)
.setOnMenuItemClickListener {
pendingUploadsFragment!!.deleteUploads()
true
}
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
} else {
if (menu!!.findItem(R.id.resume_icon) == null) {
menu!!.add(
Menu.NONE,
R.id.resume_icon,
Menu.NONE,
getString(R.string.resume)
)
.setIcon(R.drawable.play_icon)
.setOnMenuItemClickListener {
pendingUploadsFragment!!.restartUploads()
setPausedIcon(false)
true
}
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
}
}
} else if (currentPosition == 1) {
if (isErrorIconsVisisble) {
if (menu!!.findItem(R.id.retry_icon) == null) {
menu!!.add(Menu.NONE, R.id.retry_icon, Menu.NONE, getString(R.string.retry))
.setIcon(R.drawable.ic_refresh_24dp).setOnMenuItemClickListener {
failedUploadsFragment!!.restartUploads()
true
}
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
if (menu!!.findItem(R.id.cancel_icon) == null) {
menu!!.add(
Menu.NONE,
R.id.cancel_icon,
Menu.NONE,
getString(R.string.cancel)
)
.setIcon(R.drawable.ic_cancel_upload)
.setOnMenuItemClickListener {
failedUploadsFragment!!.deleteUploads()
true
}
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
}
}
}
}
/**
* Hides the menu icons related to pending uploads.
*/
fun hidePendingIcons() {
isPendingIconsVisible = false
updateMenuItems(binding.uploadProgressViewPager.currentItem)
}
/**
* Sets the paused state and updates the menu items accordingly.
* @param paused A boolean indicating whether all the uploads are paused.
*/
fun setPausedIcon(paused: Boolean) {
isPaused = paused
updateMenuItems(binding.uploadProgressViewPager.currentItem)
}
/**
* Sets the visibility of the menu icons related to failed uploads.
* @param visible A boolean indicating whether the error icons should be visible.
*/
fun setErrorIconsVisibility(visible: Boolean) {
isErrorIconsVisisble = visible
updateMenuItems(binding.uploadProgressViewPager.currentItem)
}
}

View file

@ -34,13 +34,11 @@ import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.upload.StashUploadResult
import fr.free.nrw.commons.upload.StashUploadState
import fr.free.nrw.commons.upload.UploadClient
import fr.free.nrw.commons.upload.UploadProgressActivity
import fr.free.nrw.commons.upload.UploadResult
import fr.free.nrw.commons.wikidata.WikidataEditService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -106,7 +104,6 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
getNotificationBuilder(CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL)!!
statesToProcess.add(Contribution.STATE_QUEUED)
statesToProcess.add(Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE)
}
@dagger.Module
@ -166,7 +163,8 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
}
override suspend fun doWork(): Result {
var countUpload = 0
try {
var totalUploadsStarted = 0
// Start a foreground service
setForeground(createForegroundInfo())
notificationManager = NotificationManagerCompat.from(appContext)
@ -174,6 +172,13 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
CommonsApplication.NOTIFICATION_CHANNEL_ID_ALL
)!!
withContext(Dispatchers.IO) {
while (contributionDao.getContribution(statesToProcess)
.blockingGet().size > 0 && contributionDao.getContribution(
arrayListOf(
Contribution.STATE_IN_PROGRESS
)
).blockingGet().size == 0
) {
/*
queuedContributions receives the results from a one-shot query.
This means that once the list has been fetched from the database,
@ -188,8 +193,6 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.blockingGet()
//Showing initial notification for the number of uploads being processed
Timber.e("Queued Contributions: " + queuedContributions.size)
processingUploads.setContentTitle(appContext.getString(R.string.starting_uploads))
processingUploads.setContentText(
appContext.resources.getQuantityString(
@ -204,45 +207,20 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
processingUploads.build()
)
/**
* To avoid race condition when multiple of these workers are working, assign this state
so that the next one does not process these contribution again
*/
queuedContributions.forEach {
it.state = Contribution.STATE_IN_PROGRESS
contributionDao.saveSynchronous(it)
}
val sortedQueuedContributionsList: List<Contribution> =
queuedContributions.sortedBy { it.dateUploadStartedInMillis() }
queuedContributions.asFlow().map { contribution ->
// Upload the contribution if it has not been cancelled by the user
if (!CommonsApplication.cancelledUploads.contains(contribution.pageId)) {
/**
* If the limited connection mode is on, lets iterate through the queued
* contributions
* and set the state as STATE_QUEUED_LIMITED_CONNECTION_MODE ,
* otherwise proceed with the upload
*/
if (isLimitedConnectionModeEnabled()) {
if (contribution.state == Contribution.STATE_QUEUED) {
contribution.state = Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE
contributionDao.saveSynchronous(contribution)
}
} else {
var contribution = sortedQueuedContributionsList.first()
if (contributionDao.getContribution(contribution.pageId) != null) {
contribution.transferred = 0
contribution.state = Contribution.STATE_IN_PROGRESS
contributionDao.saveSynchronous(contribution)
setProgressAsync(Data.Builder().putInt("progress", countUpload).build())
countUpload++
setProgressAsync(Data.Builder().putInt("progress", totalUploadsStarted).build())
totalUploadsStarted++
uploadContribution(contribution = contribution)
}
} else {
/* We can remove the cancelled upload from the hashset
as this contribution will not be processed again
*/
removeUploadFromInMemoryHashSet(contribution)
}
}.collect()
//Dismiss the global notification
notificationManager?.cancel(
PROCESSING_UPLOADS_NOTIFICATION_TAG,
@ -258,13 +236,12 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
}
return Result.success()
} catch (e: Exception) {
Timber.e(e, "UploadWorker encountered an error.")
return Result.failure()
} finally {
WorkRequestHelper.markUploadWorkerAsStopped()
}
/**
* Removes the processed contribution from the cancelledUploads in-memory hashset
*/
private fun removeUploadFromInMemoryHashSet(contribution: Contribution) {
CommonsApplication.cancelledUploads.remove(contribution.pageId)
}
/**
@ -287,12 +264,6 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
.setContentTitle(appContext.getString(R.string.upload_in_progress))
.build()
}
/**
* Returns true is the limited connection mode is enabled
*/
private fun isLimitedConnectionModeEnabled(): Boolean {
return sessionManager.getPreference(CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED)
}
/**
* Upload the contribution
@ -343,7 +314,6 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
).onErrorReturn{
return@onErrorReturn StashUploadResult(StashUploadState.FAILED,fileKey = null,errorMessage = it.message)
}.blockingSingle()
when (stashUploadResult.state) {
StashUploadState.SUCCESS -> {
//If the stash upload succeeds
@ -403,14 +373,19 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
contribution.state = Contribution.STATE_PAUSED
contributionDao.saveSynchronous(contribution)
}
StashUploadState.CANCELLED -> {
showCancelledNotification(contribution)
}
else -> {
Timber.e("""upload file to stash failed with status: ${stashUploadResult.state}""")
showInvalidLoginNotification(contribution)
contribution.state = Contribution.STATE_FAILED
contribution.chunkInfo = null
contribution.errorInfo = stashUploadResult.errorMessage
showErrorNotification(contribution)
contributionDao.saveSynchronous(contribution)
if (stashUploadResult.errorMessage.equals(CsrfTokenClient.INVALID_TOKEN_ERROR_MESSAGE)) {
Timber.e("Invalid Login, logging out")
showInvalidLoginNotification(contribution)
val username = sessionManager.userName
var logoutListener = CommonsApplication.BaseLogoutListener(
appContext,
@ -426,6 +401,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
Timber.e(exception)
Timber.e("Stash upload failed for contribution: $filename")
showFailedNotification(contribution)
contribution.errorInfo=exception.message
contribution.state=Contribution.STATE_FAILED
clearChunks(contribution)
}
@ -543,6 +519,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
private fun showSuccessNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle
contribution.state=Contribution.STATE_COMPLETED
curentNotification.setContentIntent(getPendingIntent(MainActivity::class.java))
curentNotification.setContentTitle(
appContext.getString(
R.string.upload_completed_notification_title,
@ -565,7 +542,7 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
@SuppressLint("StringFormatInvalid")
private fun showFailedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(MainActivity::class.java))
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle(
appContext.getString(
R.string.upload_failed_notification_title,
@ -598,12 +575,34 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
)
}
/**
* Shows a notification for a failed contribution upload.
*/
@SuppressLint("StringFormatInvalid")
private fun showErrorNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle
curentNotification.setContentTitle(
appContext.getString(
R.string.upload_failed_notification_title,
displayTitle
)
)
.setContentText(contribution.errorInfo)
.setProgress(0, 0, false)
.setOngoing(false)
notificationManager?.notify(
currentNotificationTag, currentNotificationID,
curentNotification.build()
)
}
/**
* Notify that the current upload is paused
* @param contribution
*/
private fun showPausedNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle(
appContext.getString(
R.string.upload_paused_notification_title,
@ -619,6 +618,25 @@ class UploadWorker(var appContext: Context, workerParams: WorkerParameters) :
)
}
/**
* Notify that the current upload is cancelled
* @param contribution
*/
private fun showCancelledNotification(contribution: Contribution) {
val displayTitle = contribution.media.displayTitle
curentNotification.setContentIntent(getPendingIntent(UploadProgressActivity::class.java))
curentNotification.setContentTitle(
displayTitle
)
.setContentText("Upload has been cancelled!")
.setProgress(0, 0, false)
.setOngoing(false)
notificationManager!!.notify(
currentNotificationTag, currentNotificationID,
curentNotification.build()
)
}
/**
* Method used to get Pending intent for opening different screen after clicking on notification
* @param toClass

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.upload.worker
import android.content.Context
import androidx.work.*
import androidx.work.WorkRequest.Companion.MIN_BACKOFF_MILLIS
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
@ -11,7 +12,22 @@ import java.util.concurrent.TimeUnit
class WorkRequestHelper {
companion object {
private var isUploadWorkerRunning = false
private val lock = Object()
fun makeOneTimeWorkRequest(context: Context, existingWorkPolicy: ExistingWorkPolicy) {
synchronized(lock) {
if (isUploadWorkerRunning) {
Timber.e("UploadWorker is already running. Cannot start another instance.")
return
} else {
Timber.e("Setting isUploadWorkerRunning to true")
isUploadWorkerRunning = true
}
}
/* Set backoff criteria for the work request
The default backoff policy is EXPONENTIAL, but while testing we found that it
too long for the uploads to finish. So, set the backoff policy as LINEAR with the
@ -35,7 +51,17 @@ class WorkRequestHelper {
WorkManager.getInstance(context).enqueueUniqueWork(
UploadWorker::class.java.simpleName, existingWorkPolicy, uploadRequest
)
}
}
/**
* Sets the flag isUploadWorkerRunning to`false` allowing new worker to be started.
*/
fun markUploadWorkerAsStopped() {
synchronized(lock) {
isUploadWorkerRunning = false
}
}
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/mediaDetailsHeadingText"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<group
android:scaleX="1.44427"
android:scaleY="1.44427"
android:translateX="-5.33124"
android:translateY="-5.33124">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</group>
</vector>

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="@dimen/half_standard_height"
android:height="@dimen/half_standard_height"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<group android:scaleX="1.44427"
android:scaleY="1.44427"
android:translateX="-5.33124"
android:translateY="-5.33124">
<path
android:fillColor="?attr/mediaDetailsHeadingText"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</group>
</vector>

View file

@ -5,7 +5,7 @@
android:viewportWidth="24.0">
<path
android:fillColor="#FFFFFFFF"
android:fillColor="@color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/primaryDarkColor"
android:pathData="M5,20h14v-2H5V20zM5,8h4v8h6v-8h4l-7,-7L5,8z" />
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M5,20h14v-2H5V20zM5,8h4v8h6v-8h4l-7,-7L5,8z" />
</vector>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
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:orientation="vertical"
tools:context=".upload.UploadProgressActivity">
<include
android:id="@+id/toolbarBinding"
layout="@layout/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/upload_progress_toolbar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/card_light_grey">
<com.google.android.material.tabs.TabLayout
android:id="@+id/upload_progress_tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/toolbar"
android:background="?attr/tabBackground"
app:tabIndicatorColor="?attr/tabIndicatorColor"
app:tabMode="fixed"
app:tabSelectedTextColor="?attr/tabSelectedTextColor"
app:tabTextColor="?attr/tabTextColor" />
</com.google.android.material.appbar.AppBarLayout>
<fr.free.nrw.commons.explore.ParentViewPager
android:id="@+id/upload_progress_view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/upload_progress_toolbar_layout"
android:background="?attr/mainBackground" />
</RelativeLayout>
</LinearLayout>

View file

@ -18,36 +18,6 @@
android:layout_marginTop="@dimen/miniscule_margin"
android:layout_margin="@dimen/very_tiny_gap"/>
<LinearLayout
android:id="@+id/limited_connection_enabled_layout"
android:animateLayoutChanges="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/miniscule_margin"
android:padding="@dimen/standard_gap"
android:orientation="vertical"
android:clickable="true"
android:focusable="true"
android:background="@color/wikimedia_green">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="5dp"
android:textColor="@android:color/white"
android:layout_marginBottom="@dimen/tiny_gap"
android:textSize="@dimen/subheading_text_size"
android:text="@string/limited_connection_is_on"
app:drawableTint="@color/white"
app:drawableStartCompat="@drawable/ic_baseline_cloud_off_24"/>
<TextView
android:id="@+id/limited_connection_description_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:textSize="@dimen/description_text_size"
android:text="@string/limited_connection_explanation"/>
</LinearLayout>
<FrameLayout
android:id="@+id/explore_container"
android:layout_width="match_parent"

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
tools:context=".upload.FailedUploadsFragment">
<TextView
android:id="@+id/nofailedTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="You do not have any failed Uploads!" />
<LinearLayout
android:id="@+id/failedUplaodsLl"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/failed_uploads_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="10dp" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
tools:context=".upload.PendingUploadsFragment">
<TextView
android:id="@+id/nopendingTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="You do not have any pending Uploads!"
android:visibility="gone" />
<LinearLayout
android:id="@+id/pendingUplaodsLl"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:gravity="bottom"
android:orientation="horizontal">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="Progress:"
android:textSize="22sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/progress_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right"
android:text=""
android:textSize="21sp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pending_uploads_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="10dp" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:fresco="http://schemas.android.com/tools"
android:paddingBottom="8dp"
android:gravity="center"
android:orientation="horizontal">
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/itemImage"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="?attr/mainBackground"
app:actualImageScaleType="centerCrop"
fresco:placeholderImage="@drawable/ic_image_black_24dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="6dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp"/>
<ProgressBar
android:id="@+id/itemProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/errorTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Queued"
android:visibility="gone" />
</LinearLayout>
<ImageView
android:id="@+id/retryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dimen_10"
android:src="@drawable/ic_refresh_24dp" />
<ImageView
android:id="@+id/deleteButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_cancel_upload" />
</LinearLayout>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:fresco="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:paddingBottom="8dp">
<com.facebook.drawee.view.SimpleDraweeView
android:id="@+id/itemImage"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginBottom="8dp"
android:background="?attr/mainBackground"
app:actualImageScaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
fresco:placeholderImage="@drawable/ic_image_black_24dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:paddingHorizontal="6dp"
app:layout_constraintEnd_toStartOf="@+id/deleteButton"
app:layout_constraintStart_toEndOf="@+id/itemImage">
<TextView
android:id="@+id/titleTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp" />
<ProgressBar
android:id="@+id/itemProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="visible" />
<TextView
android:id="@+id/errorTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Queued"
android:visibility="gone" />
</LinearLayout>
<ImageView
android:id="@+id/deleteButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_cancel_upload"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -104,40 +104,6 @@
android:paddingTop="@dimen/standard_gap"
android:visibility="visible">
<ImageButton
android:id="@+id/pauseResumeButton"
android:layout_width="@dimen/dimen_40"
android:layout_height="@dimen/dimen_40"
android:layout_marginEnd="@dimen/tiny_padding"
android:layout_toStartOf="@id/cancelButton"
android:background="@android:color/transparent"
android:tag="@string/pause"
app:srcCompat="@drawable/pause_icon" />
<ImageButton
android:id="@+id/cancelButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="@dimen/tiny_padding"
android:layout_toStartOf="@id/retryButton"
android:background="@android:color/transparent"
android:padding="@dimen/activity_margin_horizontal"
android:src="@drawable/ic_cancel_white"
android:tint="?attr/contributionsListTextSecondary"
android:text="@string/menu_cancel_upload" />
<ImageButton
android:id="@+id/retryButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="@dimen/tiny_padding"
android:layout_toStartOf="@id/wikipediaButton"
android:background="@android:color/transparent"
android:padding="@dimen/activity_margin_horizontal"
android:src="@drawable/ic_retry_white"
android:tint="?attr/contributionsListTextSecondary"
android:text="@string/menu_retry_upload" />
<ImageButton
android:id="@+id/wikipediaButton"
android:layout_width="48dp"

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:clickable="true"
android:focusable="true"
android:gravity="center"
tools:background="@color/black">
<ImageView
android:id="@+id/pending_uploads_image_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginEnd="@dimen/activity_margin_horizontal"
android:layout_marginRight="@dimen/activity_margin_horizontal"
android:gravity="center"
app:srcCompat="?attr/upload_icon_drawable" />
<TextView
android:id="@+id/pending_uploads_count_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/pending_uploads_image_view"
android:layout_alignEnd="@id/pending_uploads_image_view"
android:layout_alignRight="@id/pending_uploads_image_view"
android:background="@drawable/notification_badge"
android:backgroundTint="@color/button_blue"
android:gravity="center"
android:padding="@dimen/miniscule_margin"
android:textColor="@color/white"
android:textSize="7sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="9+"
tools:visibility="visible" />
<TextView
android:id="@+id/uploads_error_count_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/pending_uploads_count_badge"
android:layout_alignEnd="@id/pending_uploads_image_view"
android:layout_alignRight="@id/pending_uploads_image_view"
android:layout_marginTop="1dp"
android:layout_marginEnd="0dp"
android:layout_marginRight="0dp"
android:background="@drawable/notification_badge"
android:gravity="center"
android:padding="@dimen/miniscule_margin"
android:textColor="?attr/notification_icon_text_color"
android:textSize="7sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="9+"
tools:visibility="visible" />
</RelativeLayout>

View file

@ -1,16 +1,14 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/toggle_limited_connection_mode"
android:title="@string/limited_connection_mode"
app:showAsAction="always"
android:checkable="true"
android:icon="@drawable/ic_baseline_cloud_queue_24"
/>
<item android:id="@+id/notifications"
android:title="@string/notifications"
app:showAsAction="ifRoom|withText"
<item
android:id="@+id/upload_tab"
android:title="Upload"
app:actionLayout="@layout/pending_uploads_icon"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/notifications"
android:menuCategory="secondary"
android:title="@string/notifications"
app:actionLayout="@layout/notification_icon"
/>
app:showAsAction="ifRoom|withText" />
</menu>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".upload.UploadProgressActivity"
>
<item
android:id="@+id/resume_icon"
android:title="Resume"
android:icon="@drawable/play_icon"
android:orderInCategory="1"
app:showAsAction="ifRoom"
/>
<item
android:id="@+id/pause_icon"
android:title="Pause"
android:icon="@drawable/pause_icon"
android:orderInCategory="1"
app:showAsAction="ifRoom"
/>
<item
android:id="@+id/retry_icon"
android:title="Retry"
android:icon="@drawable/ic_refresh_24dp"
android:orderInCategory="1"
app:showAsAction="ifRoom"
/>
<item
android:id="@+id/cancel_icon"
android:title="Cancel"
android:icon="@drawable/ic_remove"
android:orderInCategory="1"
app:showAsAction="ifRoom"
/>
</menu>

View file

@ -42,6 +42,7 @@
<attr name="more_bottom_sheet_drawable_color" format="reference"/>
<attr name="card_item_color" format="reference"/>
<attr name="notification_icon_drawable" format="reference"/>
<attr name="upload_icon_drawable" format="reference"/>
<attr name="notification_icon_text_color" format="reference"/>
<attr name="toggle_theme" format="reference"/>
<attr name="contributionsListTextSecondary" format="reference"/>

View file

@ -826,5 +826,10 @@ Upload your first media by tapping on the add button.</string>
<string name="is_at_a_different_place_please_specify_the_correct_place_below_if_possible_tell_us_the_correct_latitude_longitude">\'%1$s\' is at a different place. Please specify the correct place below, and if possible, write the correct latitude and longitude.</string>
<string name="other_problem_or_information_please_explain_below">Other problem or information (please explain below).</string>
<string name="feedback_destination_note">Your feedback gets posted to the following wiki page: <![CDATA[ <a href="https://commons.wikimedia.org/wiki/Commons:Mobile_app/Feedback">Commons:Mobile app/Feedback</a> ]]></string>
<string name="are_you_sure_that_you_want_cancel_all_the_uploads">Are you sure that you want cancel all the uploads?</string>
<string name="cancelling_all_the_uploads">Cancelling all the uploads...</string>
<string name="uploads">Uploads</string>
<string name="pending">Pending</string>
<string name="failed">Failed</string>
<string name="could_not_load_place_data">Could not load place data</string>
</resources>

View file

@ -48,6 +48,7 @@
<item name="more_bottom_sheet_style">@style/DarkMoreBottomSheetStyle</item>
<item name="more_bottom_sheet_drawable_color">@color/white</item>
<item name="card_item_color">@color/white</item>
<item name="upload_icon_drawable">@drawable/ic_upload_white_24dp</item>
<item name="notification_icon_drawable">@drawable/ic_notifications_white_24dp</item>
<item name="notification_icon_text_color">@color/white</item>
<item name="toggle_theme">@style/SwitchThemeDark</item>
@ -108,6 +109,7 @@
<item name="more_bottom_sheet_style">@style/LightMoreBottomSheetStyle</item>
<item name="more_bottom_sheet_drawable_color">@color/black</item>
<item name="card_item_color">@color/primaryDarkColor</item>
<item name="upload_icon_drawable">@drawable/ic_upload_blue_24dp</item>
<item name="notification_icon_drawable">@drawable/ic_notifications_blue_24dp</item>
<item name="notification_icon_text_color">@color/primaryDarkColor</item>
<item name="toggle_theme">@style/SwitchThemeLight</item>

View file

@ -92,63 +92,6 @@ class ContributionViewHolderUnitTests {
Assert.assertNotNull(contributionViewHolder)
}
@Test
@Throws(Exception::class)
fun testSetResume() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method = ContributionViewHolder::class.java.getDeclaredMethod(
"setResume"
)
method.isAccessible = true
method.invoke(contributionViewHolder)
}
@Test
@Throws(Exception::class)
fun testSetPaused() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method = ContributionViewHolder::class.java.getDeclaredMethod(
"setPaused"
)
method.isAccessible = true
method.invoke(contributionViewHolder)
}
@Test
@Throws(Exception::class)
fun testPause() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method = ContributionViewHolder::class.java.getDeclaredMethod(
"pause"
)
method.isAccessible = true
method.invoke(contributionViewHolder)
}
@Test
@Throws(Exception::class)
fun testResume() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
val method: Method = ContributionViewHolder::class.java.getDeclaredMethod(
"resume"
)
method.isAccessible = true
method.invoke(contributionViewHolder)
}
@Test
@Throws(Exception::class)
fun testOnPauseResumeButtonClickedCaseTrue() {
contributionViewHolder.onPauseResumeButtonClicked()
}
@Test
@Throws(Exception::class)
fun testOnPauseResumeButtonClickedCaseFalse() {
bindind.pauseResumeButton.tag = ""
contributionViewHolder.onPauseResumeButtonClicked()
}
@Test
@Throws(Exception::class)
fun testWikipediaButtonClicked() {
@ -161,18 +104,6 @@ class ContributionViewHolderUnitTests {
contributionViewHolder.imageClicked()
}
@Test
@Throws(Exception::class)
fun testDeleteUpload() {
contributionViewHolder.deleteUpload()
}
@Test
@Throws(Exception::class)
fun testRetryUpload() {
contributionViewHolder.retryUpload()
}
@Test
@Throws(Exception::class)
fun testChooseImageSource() {
@ -240,17 +171,6 @@ class ContributionViewHolderUnitTests {
contributionViewHolder.init(0, contribution)
}
@Test
@Throws(Exception::class)
fun testInitCaseNonNull_STATE_QUEUED_LIMITED_CONNECTION_MODE() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(contribution.state).thenReturn(Contribution.STATE_QUEUED_LIMITED_CONNECTION_MODE)
`when`(contribution.media).thenReturn(media)
`when`(media.mostRelevantCaption).thenReturn("")
`when`(media.author).thenReturn("")
contributionViewHolder.init(0, contribution)
}
@Test
@Throws(Exception::class)
fun testInitCaseNonNull_STATE_IN_PROGRESS() {

View file

@ -205,7 +205,6 @@ class ContributionsFragmentUnitTests {
`when`(menu.findItem(anyInt())).thenReturn(menuItem)
`when`(menuItem.actionView).thenReturn(notification)
`when`(store.getBoolean(anyString(), anyBoolean())).thenReturn(true)
fragment.updateLimitedConnectionToggle(menu)
}
@Test

View file

@ -137,20 +137,6 @@ class ContributionsListFragmentUnitTests {
method.invoke(fragment, contribution)
}
@Test
@Throws(Exception::class)
fun testResumeUpload() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.resumeUpload(contribution)
}
@Test
@Throws(Exception::class)
fun testPauseUpload() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.pauseUpload(contribution)
}
@Test
@Throws(Exception::class)
fun testAddImageToWikipedia() {
@ -165,20 +151,6 @@ class ContributionsListFragmentUnitTests {
fragment.openMediaDetail(0, true)
}
@Test
@Throws(Exception::class)
fun testDeleteUpload() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.deleteUpload(contribution)
}
@Test
@Throws(Exception::class)
fun testRetryUpload() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
fragment.retryUpload(contribution)
}
@Test
@Throws(Exception::class)
fun testOnViewStateRestored() {

View file

@ -54,12 +54,4 @@ class ContributionsListPresenterTest {
);
}
@Test
fun testDeleteUpload() {
whenever(repository.deleteContributionFromDB(any<Contribution>()))
.thenReturn(Completable.complete())
contributionsListPresenter.deleteUpload(mock(Contribution::class.java))
verify(repository, times(1))
.deleteContributionFromDB(ArgumentMatchers.any(Contribution::class.java));
}
}

View file

@ -8,6 +8,7 @@ import androidx.loader.content.CursorLoader
import androidx.loader.content.Loader
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.repository.UploadRepository
import io.reactivex.Completable
import io.reactivex.schedulers.TestScheduler
import org.junit.Before
@ -24,6 +25,10 @@ import org.mockito.MockitoAnnotations
class ContributionsPresenterTest {
@Mock
internal lateinit var repository: ContributionsRepository
@Mock
internal lateinit var uploadRepository: UploadRepository
@Mock
internal lateinit var view: ContributionsContract.View
@ -37,9 +42,11 @@ class ContributionsPresenterTest {
lateinit var liveData: LiveData<List<Contribution>>
@Rule @JvmField var instantTaskExecutorRule = InstantTaskExecutorRule()
@Rule
@JvmField
var instantTaskExecutorRule = InstantTaskExecutorRule()
lateinit var scheduler : TestScheduler
lateinit var scheduler: TestScheduler
/**
* initial setup
@ -48,35 +55,23 @@ class ContributionsPresenterTest {
@Throws(Exception::class)
fun setUp() {
MockitoAnnotations.initMocks(this)
scheduler=TestScheduler()
scheduler = TestScheduler()
cursor = Mockito.mock(Cursor::class.java)
contribution = Mockito.mock(Contribution::class.java)
contributionsPresenter = ContributionsPresenter(repository, scheduler)
contributionsPresenter = ContributionsPresenter(repository, uploadRepository, scheduler)
loader = Mockito.mock(CursorLoader::class.java)
contributionsPresenter.onAttachView(view)
liveData=MutableLiveData()
}
/**
* Test presenter actions onDeleteContribution
*/
@Test
fun testDeleteContribution() {
whenever(repository.deleteContributionFromDB(ArgumentMatchers.any<Contribution>()))
.thenReturn(Completable.complete())
contributionsPresenter.deleteUpload(contribution)
verify(repository).deleteContributionFromDB(contribution)
liveData = MutableLiveData()
}
/**
* Test fetch contribution with filename
*/
@Test
fun testGetContributionWithFileName(){
fun testGetContributionWithFileName() {
contributionsPresenter.getContributionsWithTitle("ashish")
verify(repository).getContributionWithFileName("ashish")
}
}

View file

@ -195,25 +195,6 @@ class MainActivityUnitTests {
MainActivity.startYourself(mockContext)
}
@Test
@Throws(Exception::class)
fun testToggleLimitedConnectionModeCaseDefault() {
activity.toggleLimitedConnectionMode()
}
@Test
@Throws(Exception::class)
fun testToggleLimitedConnectionMode() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
`when`(
defaultKvStore.getBoolean(
CommonsApplication.IS_LIMITED_CONNECTION_MODE_ENABLED, false
)
)
.thenReturn(false)
activity.toggleLimitedConnectionMode()
}
@Test
@Throws(Exception::class)
fun testSetUpPager() {

View file

@ -15,19 +15,25 @@ import fr.free.nrw.commons.CommonsApplication.DEFAULT_EDIT_SUMMARY
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.contributions.ChunkInfo
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.upload.UploadClient.TimeProvider
import fr.free.nrw.commons.wikidata.mwapi.MwException
import fr.free.nrw.commons.wikidata.mwapi.MwServiceError
import io.reactivex.Observable
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertSame
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import org.junit.Assert
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.jupiter.api.assertThrows
import org.junit.platform.commons.annotation.Testable
import java.io.File
import java.util.Date
@ -41,14 +47,24 @@ class UploadClientTest {
private val pageContentsCreator = mock<PageContentsCreator>()
private val fileUtilsWrapper = mock<FileUtilsWrapper>()
private val gson = mock<Gson>()
private val contributionDao = mock<ContributionDao> { }
private val timeProvider = mock<TimeProvider>()
private val uploadClient = UploadClient(uploadInterface, csrfTokenClient, pageContentsCreator, fileUtilsWrapper, gson, timeProvider)
private val uploadClient = UploadClient(
uploadInterface,
csrfTokenClient,
pageContentsCreator,
fileUtilsWrapper,
gson,
timeProvider,
contributionDao
)
private val expectedChunkSize = 512 * 1024
private val testToken = "test-token"
private val createdContent = "content"
private val filename = "test.jpg"
private val filekey = "the-key"
private val pageId = "page-id"
private val errorCode = "the-code"
private val uploadJson = Gson().fromJson("{\"foo\" = 1}", JsonObject::class.java)
@ -64,7 +80,15 @@ class UploadClientTest {
@Test
fun testUploadFileFromStash_NoErrors() {
whenever(gson.fromJson(uploadJson, UploadResponse::class.java)).thenReturn(uploadResponse)
whenever(uploadInterface.uploadFileFromStash(testToken, createdContent, DEFAULT_EDIT_SUMMARY, filename, filekey)).thenReturn(Observable.just(uploadJson))
whenever(
uploadInterface.uploadFileFromStash(
testToken,
createdContent,
DEFAULT_EDIT_SUMMARY,
filename,
filekey
)
).thenReturn(Observable.just(uploadJson))
val result = uploadClient.uploadFileFromStash(contribution, filename, filekey).test()
@ -80,7 +104,15 @@ class UploadClientTest {
whenever(gson.fromJson(uploadJson, UploadResponse::class.java)).thenReturn(errorResponse)
whenever(gson.fromJson(uploadJson, MwException::class.java)).thenReturn(uploadException)
whenever(uploadInterface.uploadFileFromStash(testToken, createdContent, DEFAULT_EDIT_SUMMARY, filename, filekey)).thenReturn(Observable.just(uploadJson))
whenever(
uploadInterface.uploadFileFromStash(
testToken,
createdContent,
DEFAULT_EDIT_SUMMARY,
filename,
filekey
)
).thenReturn(Observable.just(uploadJson))
val result = uploadClient.uploadFileFromStash(contribution, filename, filekey).test()
@ -91,7 +123,15 @@ class UploadClientTest {
@Test
fun testUploadFileFromStash_Failure() {
val exception = Exception("test")
whenever(uploadInterface.uploadFileFromStash(testToken, createdContent, DEFAULT_EDIT_SUMMARY, filename, filekey))
whenever(
uploadInterface.uploadFileFromStash(
testToken,
createdContent,
DEFAULT_EDIT_SUMMARY,
filename,
filekey
)
)
.thenReturn(Observable.error(exception))
val result = uploadClient.uploadFileFromStash(contribution, filename, filekey).test()
@ -104,7 +144,8 @@ class UploadClientTest {
fun testUploadChunkToStash_Success() {
val fileContent = "content"
val requestBody: RequestBody = fileContent.toRequestBody("text/plain".toMediaType())
val countingRequestBody = CountingRequestBody(requestBody, mock(), 0, fileContent.length.toLong())
val countingRequestBody =
CountingRequestBody(requestBody, mock(), 0, fileContent.length.toLong())
val filenameCaptor: KArgumentCaptor<RequestBody> = argumentCaptor<RequestBody>()
val totalFileSizeCaptor = argumentCaptor<RequestBody>()
@ -113,12 +154,15 @@ class UploadClientTest {
val tokenCaptor = argumentCaptor<RequestBody>()
val fileCaptor = argumentCaptor<MultipartBody.Part>()
whenever(uploadInterface.uploadFileToStash(
whenever(
uploadInterface.uploadFileToStash(
filenameCaptor.capture(), totalFileSizeCaptor.capture(), offsetCaptor.capture(),
fileKeyCaptor.capture(), tokenCaptor.capture(), fileCaptor.capture()
)).thenReturn(Observable.just(uploadResponse))
)
).thenReturn(Observable.just(uploadResponse))
val result = uploadClient.uploadChunkToStash(filename, 100, 10, filekey, countingRequestBody).test()
val result =
uploadClient.uploadChunkToStash(filename, 100, 10, filekey, countingRequestBody).test()
result.assertNoErrors()
assertSame(uploadResult, result.values()[0])
@ -156,28 +200,18 @@ class UploadClientTest {
assertEquals(StashUploadState.SUCCESS, stashResult.state)
}
@Test
fun uploadFileToStash_contributionIsUnpaused() {
whenever(contribution.isCompleted()).thenReturn(false)
whenever(contribution.fileKey).thenReturn(filekey)
whenever(fileUtilsWrapper.getMimeType(anyOrNull<File>())).thenReturn("image/png")
whenever(fileUtilsWrapper.getFileChunks(anyOrNull<File>(), eq(expectedChunkSize))).thenReturn(emptyList())
val result = uploadClient.uploadFileToStash(filename, contribution, mock()).test()
result.assertNoErrors()
verify(contribution, times(1)).unpause()
}
@Test
fun uploadFileToStash_returnsFailureIfNothingToUpload() {
val tempFile = File.createTempFile("tempFile", ".tmp")
tempFile.deleteOnExit()
whenever(contribution.isCompleted()).thenReturn(false)
whenever(contribution.fileKey).thenReturn(filekey)
whenever(contribution.pageId).thenReturn(pageId)
whenever(contributionDao.getContribution(pageId)).thenReturn(contribution)
whenever(contribution.localUriPath).thenReturn(tempFile)
whenever(fileUtilsWrapper.getMimeType(anyOrNull<File>())).thenReturn("image/png")
whenever(fileUtilsWrapper.getFileChunks(anyOrNull<File>(), eq(expectedChunkSize))).thenReturn(emptyList())
val result = uploadClient.uploadFileToStash(filename, contribution, mock()).test()
val result = uploadClient.uploadFileToStash(filename, contribution, mock() ).test()
result.assertNoErrors()
assertEquals(StashUploadState.FAILED, result.values()[0].state)
}
@ -188,10 +222,26 @@ class UploadClientTest {
whenever(mockFile.length()).thenReturn(1)
whenever(contribution.localUriPath).thenReturn(mockFile)
whenever(contribution.isCompleted()).thenReturn(false)
whenever(contribution.pageId).thenReturn(pageId)
whenever(contributionDao.getContribution(pageId)).thenReturn(contribution)
whenever(contribution.fileKey).thenReturn(filekey)
whenever(fileUtilsWrapper.getMimeType(anyOrNull<File>())).thenReturn("image/png")
whenever(fileUtilsWrapper.getFileChunks(anyOrNull<File>(), eq(expectedChunkSize))).thenReturn(listOf(mockFile))
whenever(uploadInterface.uploadFileToStash(any(), any(), any(), any(), any(), any())).thenReturn(Observable.just(uploadResponse))
whenever(
fileUtilsWrapper.getFileChunks(
anyOrNull<File>(),
eq(expectedChunkSize)
)
).thenReturn(listOf(mockFile))
whenever(
uploadInterface.uploadFileToStash(
any(),
any(),
any(),
any(),
any(),
any()
)
).thenReturn(Observable.just(uploadResponse))
val result = uploadClient.uploadFileToStash(filename, contribution, mock()).test()
@ -215,12 +265,23 @@ class UploadClientTest {
whenever(contribution.dateModified).thenReturn(Date(100))
whenever(timeProvider.currentTimeMillis()).thenReturn(200)
whenever(contribution.fileKey).thenReturn(filekey)
whenever(contribution.pageId).thenReturn(pageId)
whenever(contributionDao.getContribution(pageId)).thenReturn(contribution)
whenever(fileUtilsWrapper.getMimeType(anyOrNull<File>())).thenReturn("image/png")
whenever(fileUtilsWrapper.getFileChunks(anyOrNull<File>(), eq(expectedChunkSize))).thenReturn(listOf(mockFile))
whenever(
fileUtilsWrapper.getFileChunks(
anyOrNull<File>(),
eq(expectedChunkSize)
)
).thenReturn(listOf(mockFile))
whenever(uploadInterface.uploadFileToStash(anyOrNull(), anyOrNull(), anyOrNull(),
anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(Observable.just(uploadResponse))
whenever(
uploadInterface.uploadFileToStash(
anyOrNull(), anyOrNull(), anyOrNull(),
anyOrNull(), anyOrNull(), anyOrNull()
)
).thenReturn(Observable.just(uploadResponse))
val result = uploadClient.uploadFileToStash(filename, contribution, mock()).test()

View file

@ -8,6 +8,7 @@ import fr.free.nrw.commons.Media
import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.kvstore.JsonKvStore
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.InjectMocks
import org.mockito.Mock
@ -32,6 +33,7 @@ class UploadControllerTest {
MockitoAnnotations.openMocks(this)
}
@Ignore
@Test
fun startUpload() {
val contribution = mock(Contribution::class.java)

View file

@ -6,6 +6,7 @@ import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import media
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
@ -28,6 +29,7 @@ class UploadModelUnitTest {
)
}
@Ignore
@Test
fun `Test onDepictItemClicked when DepictedItem is selected`(){
uploadModel.onDepictItemClicked(
@ -42,6 +44,7 @@ class UploadModelUnitTest {
), media(filename = "File:Example.jpg"))
}
@Ignore
@Test
fun `Test onDepictItemClicked when DepictedItem is not selected`(){
uploadModel.onDepictItemClicked(
@ -57,6 +60,7 @@ class UploadModelUnitTest {
)
}
@Ignore
@Test
fun `Test onDepictItemClicked when DepictedItem is not selected and not included in media`(){
uploadModel.onDepictItemClicked(
@ -72,6 +76,7 @@ class UploadModelUnitTest {
)
}
@Ignore
@Test
fun `Test onDepictItemClicked when media is null and DepictedItem is not selected`(){
uploadModel.onDepictItemClicked(
@ -86,6 +91,7 @@ class UploadModelUnitTest {
), null)
}
@Ignore
@Test
fun `Test onDepictItemClicked when media is not null and DepictedItem is selected`(){
uploadModel.onDepictItemClicked(
@ -100,6 +106,7 @@ class UploadModelUnitTest {
), media(filename = "File:Example.jpg"))
}
@Ignore
@Test
fun `Test onDepictItemClicked when media is null and DepictedItem is selected`(){
uploadModel.onDepictItemClicked(
@ -114,11 +121,13 @@ class UploadModelUnitTest {
), null)
}
@Ignore
@Test
fun testGetSelectedExistingDepictions(){
uploadModel.selectedExistingDepictions
}
@Ignore
@Test
fun testSetSelectedExistingDepictions(){
uploadModel.selectedExistingDepictions = listOf("")

View file

@ -9,6 +9,7 @@ import fr.free.nrw.commons.repository.UploadRepository
import fr.free.nrw.commons.upload.ImageCoordinates
import io.reactivex.Observable
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.ArgumentMatchers
import org.mockito.InjectMocks
@ -68,6 +69,7 @@ class UploadPresenterTest {
/**
* unit test case for method UploadPresenter.handleSubmit
*/
@Ignore
@Test
fun handleSubmitTestUserLoggedIn() {
`when`(view.isLoggedIn).thenReturn(true)
@ -78,6 +80,7 @@ class UploadPresenterTest {
verify(repository).buildContributions()
}
@Ignore
@Test
fun handleSubmitImagesNoLocationWithConsecutiveNoLocationUploads() {
`when`(imageCoords.imageCoordsExists).thenReturn(false)
@ -102,6 +105,7 @@ class UploadPresenterTest {
verify(view).showAlertDialog(ArgumentMatchers.anyInt(), ArgumentMatchers.any<Runnable>())
}
@Ignore
@Test
fun handleSubmitImagesWithLocationWithConsecutiveNoLocationUploads() {
`when`(
@ -117,6 +121,7 @@ class UploadPresenterTest {
.showAlertDialog(ArgumentMatchers.anyInt(), ArgumentMatchers.any<Runnable>())
}
@Ignore
@Test
fun handleSubmitTestUserLoggedInAndLimitedConnectionOn() {
`when`(
@ -136,6 +141,7 @@ class UploadPresenterTest {
/**
* unit test case for method UploadPresenter.handleSubmit
*/
@Ignore
@Test
fun handleSubmitTestUserNotLoggedIn() {
`when`(view.isLoggedIn).thenReturn(false)
@ -152,6 +158,7 @@ class UploadPresenterTest {
/**
* Test which asserts If the next fragment to be shown is not one of the MediaDetailsFragment, lets hide the top card
*/
@Ignore
@Test
fun hideTopCardWhenReachedTheLastFile(){
deletePictureBaseTest()
@ -163,6 +170,7 @@ class UploadPresenterTest {
/**
* Test media deletion during single upload
*/
@Ignore
@Test
fun testDeleteWhenSingleUpload(){
deletePictureBaseTest()
@ -176,6 +184,7 @@ class UploadPresenterTest {
/**
* Test media deletion during multiple upload
*/
@Ignore
@Test
fun testDeleteWhenMultipleFilesUpload(){
deletePictureBaseTest()

View file

@ -17,6 +17,7 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import io.reactivex.Completable
import io.reactivex.Single
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.mockito.Mock
@ -199,7 +200,6 @@ class UploadRepositoryUnitTest {
)
}
@Test
fun testDeletePicture() {
assertEquals(repository.deletePicture(""), uploadModel.deletePicture(""))

View file

@ -6,6 +6,7 @@ import entity
import entityId
import fr.free.nrw.commons.wikidata.WikidataProperties
import org.junit.Assert
import org.junit.Ignore
import org.junit.Test
import place
import snak

View file

@ -4,6 +4,7 @@ import com.nhaarman.mockitokotlin2.mock
import fr.free.nrw.commons.upload.FileUtils
import fr.free.nrw.commons.upload.FileUtilsWrapper
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import java.io.*

View file

@ -13,6 +13,7 @@ import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertSame
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.mockito.Mockito.mock

View file

@ -17,6 +17,7 @@ import org.mockito.MockitoAnnotations
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResult
import fr.free.nrw.commons.wikidata.model.Statement_partial
import org.junit.Ignore
class WikidataClientTest {

View file

@ -20,7 +20,6 @@ allprojects {
gradlePluginPortal() // potential jcenter() replacement
maven { url "https://jitpack.io" }
maven { url "https://maven.google.com" }
jcenter()
}
}
subprojects{