mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	*.kt: bulk correction of formatting using ktlint --format
This commit is contained in:
		
							parent
							
								
									f751ab4a75
								
							
						
					
					
						commit
						4aca2a26ae
					
				
					 401 changed files with 10409 additions and 8827 deletions
				
			
		|  | @ -25,7 +25,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class AboutActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) | ||||
| 
 | ||||
|  | @ -36,7 +35,8 @@ class AboutActivityTest { | |||
|         device.setOrientationNatural() | ||||
|         device.freezeRotation() | ||||
|         Intents.init() | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -47,11 +47,12 @@ class AboutActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun testBuildNumber() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_version)) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.about_version)) | ||||
|             .check( | ||||
|                 ViewAssertions.matches( | ||||
|                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()) | ||||
|                 ) | ||||
|                     withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()), | ||||
|                 ), | ||||
|             ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -61,8 +62,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.WEBSITE_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(Urls.WEBSITE_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -73,8 +74,8 @@ class AboutActivityTest { | |||
|             CoreMatchers.anyOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), | ||||
|                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME) | ||||
|             ) | ||||
|                 IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -84,8 +85,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(Urls.GITHUB_REPO_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -95,8 +96,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -108,8 +109,8 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode") | ||||
|             ) | ||||
|                 IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -119,27 +120,30 @@ class AboutActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.CREDITS_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(Urls.CREDITS_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testLaunchUserGuide() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) | ||||
|         Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|             IntentMatchers.hasData(Urls.USER_GUIDE_URL))) | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.USER_GUIDE_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     @Test | ||||
|     fun testLaunchAboutFaq() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(Urls.FAQ_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(Urls.FAQ_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -23,7 +23,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class LoginActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = ActivityTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -49,8 +48,8 @@ class LoginActivityTest { | |||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL) | ||||
|             ) | ||||
|                 IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -64,4 +63,4 @@ class LoginActivityTest { | |||
|     fun orientationChange() { | ||||
|         UITestHelper.changeOrientation(activityRule) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -27,14 +27,14 @@ import org.junit.runner.RunWith | |||
| @LargeTest | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class MainActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( | ||||
|         "android.permission.ACCESS_FINE_LOCATION" | ||||
|     ) | ||||
|     var mGrantPermissionRule: GrantPermissionRule = | ||||
|         GrantPermissionRule.grant( | ||||
|             "android.permission.ACCESS_FINE_LOCATION", | ||||
|         ) | ||||
| 
 | ||||
|     private val device: UiDevice = | ||||
|         UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) | ||||
|  | @ -48,7 +48,8 @@ class MainActivityTest { | |||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents.init() | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|         val context = InstrumentationRegistry.getInstrumentation().targetContext | ||||
|         val storeName = context.packageName + "_preferences" | ||||
|  | @ -62,137 +63,149 @@ class MainActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun testNearby() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         1, | ||||
|                     ), | ||||
|                     1 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             .check(matches(ViewMatchers.isDisplayed())) | ||||
|         UITestHelper.sleep(10000) | ||||
|         val actionMenuItemView2 = Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.list_sheet), ViewMatchers.withContentDescription("List"), | ||||
|                 childAtPosition( | ||||
|         val actionMenuItemView2 = | ||||
|             Espresso.onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withId(R.id.list_sheet), | ||||
|                     ViewMatchers.withContentDescription("List"), | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         actionMenuItemView2.perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testExplore() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         2, | ||||
|                     ), | ||||
|                     2 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             .check(matches(ViewMatchers.isDisplayed())) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testContributions() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso | ||||
|             .onView(ViewMatchers.withId(R.id.fragmentContainer)) | ||||
|             .check(matches(ViewMatchers.isDisplayed())) | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.contributionImage), | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withId(R.id.contributionImage), | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.contributionsList), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.contributionsList), | ||||
|                             0, | ||||
|                         ), | ||||
|                         1, | ||||
|                     ), | ||||
|                     1 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|         val actionMenuItemView = Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.menu_bookmark_current_image), | ||||
|                 childAtPosition( | ||||
|             ).perform(ViewActions.click()) | ||||
|         val actionMenuItemView = | ||||
|             Espresso.onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withId(R.id.menu_bookmark_current_image), | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         actionMenuItemView.perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(3000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testBookmarks() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         3, | ||||
|                     ), | ||||
|                     3 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|             ).perform(ViewActions.click()) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testNotifications() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withId(R.id.notifications), | ||||
|                 childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withId(R.id.notifications), | ||||
|                     childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.toolbar), | ||||
|                         1 | ||||
|                         childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         1, | ||||
|                     ), | ||||
|                     1 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|             ).perform(ViewActions.click()) | ||||
|         Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name)) | ||||
|         Espresso.pressBack() | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import android.app.Activity | |||
| import android.app.Instrumentation | ||||
| import androidx.test.espresso.Espresso.onView | ||||
| import androidx.test.espresso.action.ViewActions | ||||
| import androidx.test.espresso.action.ViewActions.swipeRight | ||||
| import androidx.test.espresso.intent.Intents | ||||
| import androidx.test.espresso.intent.matcher.IntentMatchers | ||||
| import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent | ||||
|  | @ -26,7 +25,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class ProfileActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = IntentsTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -38,7 +36,8 @@ class ProfileActivityTest { | |||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -50,20 +49,19 @@ class ProfileActivityTest { | |||
|                 childAtPosition( | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         0, | ||||
|                     ), | ||||
|                     4 | ||||
|                     4, | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|                 ViewMatchers.isDisplayed(), | ||||
|             ), | ||||
|         ).perform(ViewActions.click()) | ||||
|         onView(Matchers.allOf(withId(R.id.more_profile))).perform( | ||||
|             ViewActions.scrollTo(), | ||||
|             ViewActions.click() | ||||
|             ViewActions.click(), | ||||
|         ) | ||||
|         device.swipe(1033,1346,531,1346,20) | ||||
|         device.swipe(1033, 1346, 531, 1346, 20) | ||||
|         UITestHelper.sleep(5000) | ||||
|         Intents.intended(hasComponent(ProfileActivity::class.java.name)) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -9,7 +9,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class ReviewActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) | ||||
| 
 | ||||
|  | @ -17,5 +16,4 @@ class ReviewActivityTest { | |||
|     fun orientationChange() { | ||||
|         UITestHelper.changeOrientation(activityRule) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -16,7 +16,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SearchActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = ActivityTestRule(SearchActivity::class.java) | ||||
| 
 | ||||
|  | @ -31,21 +30,22 @@ class SearchActivityTest { | |||
| 
 | ||||
|     @Test | ||||
|     fun exploreActivityTest() { | ||||
|         val searchAutoComplete = Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 UITestHelper.childAtPosition( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||
|                         UITestHelper.childAtPosition( | ||||
|         val searchAutoComplete = | ||||
|             Espresso.onView( | ||||
|                 Matchers.allOf( | ||||
|                     UITestHelper.childAtPosition( | ||||
|                         Matchers.allOf( | ||||
|                             ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||
|                             1 | ||||
|                         ) | ||||
|                             UITestHelper.childAtPosition( | ||||
|                                 ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), | ||||
|                                 1, | ||||
|                             ), | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard()) | ||||
|         UITestHelper.sleep(5000) | ||||
|         device.swipe(1000, 1400, 500, 1400, 20) | ||||
|  | @ -56,4 +56,4 @@ class SearchActivityTest { | |||
|         device.swipe(800, 1400, 600, 1400, 20) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -22,7 +22,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SettingsActivityLoggedInTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) | ||||
| 
 | ||||
|  | @ -35,31 +34,32 @@ class SettingsActivityLoggedInTest { | |||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun testSettings() { | ||||
|         Espresso.onView( | ||||
|             Matchers.allOf( | ||||
|                 ViewMatchers.withContentDescription("More"), | ||||
|                 UITestHelper.childAtPosition( | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 Matchers.allOf( | ||||
|                     ViewMatchers.withContentDescription("More"), | ||||
|                     UITestHelper.childAtPosition( | ||||
|                         ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         UITestHelper.childAtPosition( | ||||
|                             ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         4, | ||||
|                     ), | ||||
|                     4 | ||||
|                     ViewMatchers.isDisplayed(), | ||||
|                 ), | ||||
|                 ViewMatchers.isDisplayed() | ||||
|             ) | ||||
|         ).perform(ViewActions.click()) | ||||
|             ).perform(ViewActions.click()) | ||||
|         Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform( | ||||
|             ViewActions.scrollTo(), | ||||
|             ViewActions.click() | ||||
|             ViewActions.click(), | ||||
|         ) | ||||
|         Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) | ||||
|         UITestHelper.sleep(1000) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -23,7 +23,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class SettingsActivityTest { | ||||
| 
 | ||||
|     private lateinit var defaultKvStore: JsonKvStore | ||||
| 
 | ||||
|     @get:Rule | ||||
|  | @ -44,22 +43,24 @@ class SettingsActivityTest { | |||
|     fun useAuthorNameTogglesOn() { | ||||
|         // Turn on "Use author name" preference if currently off | ||||
|         if (!defaultKvStore.getBoolean("useAuthorName", false)) { | ||||
|             Espresso.onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.recycler_view), | ||||
|                     childAtPosition(withId(android.R.id.list_container), 0) | ||||
|             Espresso | ||||
|                 .onView( | ||||
|                     allOf( | ||||
|                         withId(R.id.recycler_view), | ||||
|                         childAtPosition(withId(android.R.id.list_container), 0), | ||||
|                     ), | ||||
|                 ).perform( | ||||
|                     RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()), | ||||
|                 ) | ||||
|             ).perform( | ||||
|                 RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(6, click()) | ||||
|             ) | ||||
|         } | ||||
|         // Check authorName preference is enabled | ||||
|         Espresso.onView( | ||||
|             allOf( | ||||
|                 withId(R.id.recycler_view), | ||||
|                 childAtPosition(withId(android.R.id.list_container), 0) | ||||
|             ) | ||||
|         ).check(matches(isEnabled())) | ||||
|         Espresso | ||||
|             .onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.recycler_view), | ||||
|                     childAtPosition(withId(android.R.id.list_container), 0), | ||||
|                 ), | ||||
|             ).check(matches(isEnabled())) | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|  |  | |||
|  | @ -13,14 +13,13 @@ import org.apache.commons.lang3.StringUtils | |||
| import org.hamcrest.* | ||||
| import timber.log.Timber | ||||
| 
 | ||||
| 
 | ||||
| class UITestHelper { | ||||
|     companion object { | ||||
|         fun skipWelcome() { | ||||
|             try { | ||||
|                 onView(ViewMatchers.withId(R.id.button_ok)) | ||||
|                     .perform(ViewActions.click()) | ||||
|                 //Skip tutorial | ||||
|                 // Skip tutorial | ||||
|                 onView(ViewMatchers.withId(R.id.finishTutorialButton)) | ||||
|                     .perform(ViewActions.click()) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|  | @ -29,27 +28,31 @@ class UITestHelper { | |||
| 
 | ||||
|         fun skipLogin() { | ||||
|             try { | ||||
|                 //Skip Login | ||||
|                 val htmlTextView = onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(R.id.skip_login), ViewMatchers.withText("Skip"), | ||||
|                         ViewMatchers.isDisplayed() | ||||
|                 // Skip Login | ||||
|                 val htmlTextView = | ||||
|                     onView( | ||||
|                         Matchers.allOf( | ||||
|                             ViewMatchers.withId(R.id.skip_login), | ||||
|                             ViewMatchers.withText("Skip"), | ||||
|                             ViewMatchers.isDisplayed(), | ||||
|                         ), | ||||
|                     ) | ||||
|                 ) | ||||
|                 htmlTextView.perform(ViewActions.click()) | ||||
| 
 | ||||
|                 val appCompatButton = onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), | ||||
|                         childAtPosition( | ||||
|                 val appCompatButton = | ||||
|                     onView( | ||||
|                         Matchers.allOf( | ||||
|                             ViewMatchers.withId(android.R.id.button1), | ||||
|                             ViewMatchers.withText("Yes"), | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                 0 | ||||
|                                 childAtPosition( | ||||
|                                     ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                     0, | ||||
|                                 ), | ||||
|                                 3, | ||||
|                             ), | ||||
|                             3 | ||||
|                         ) | ||||
|                         ), | ||||
|                     ) | ||||
|                 ) | ||||
|                 appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|             } | ||||
|  | @ -57,18 +60,18 @@ class UITestHelper { | |||
| 
 | ||||
|         fun loginUser() { | ||||
|             try { | ||||
|                 //Perform Login | ||||
|                 // Perform Login | ||||
|                 sleep(3000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_username)) | ||||
|                     .perform( | ||||
|                         ViewActions.replaceText(getTestUsername()), | ||||
|                         ViewActions.closeSoftKeyboard() | ||||
|                         ViewActions.closeSoftKeyboard(), | ||||
|                     ) | ||||
|                 sleep(2000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_password)) | ||||
|                     .perform( | ||||
|                         ViewActions.replaceText(getTestUserPassword()), | ||||
|                         ViewActions.closeSoftKeyboard() | ||||
|                         ViewActions.closeSoftKeyboard(), | ||||
|                     ) | ||||
|                 sleep(2000) | ||||
|                 onView(ViewMatchers.withId(R.id.login_button)) | ||||
|  | @ -76,7 +79,6 @@ class UITestHelper { | |||
|                 sleep(10000) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         fun logoutUser() { | ||||
|  | @ -87,36 +89,38 @@ class UITestHelper { | |||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), | ||||
|                                 0 | ||||
|                                 0, | ||||
|                             ), | ||||
|                             4 | ||||
|                             4, | ||||
|                         ), | ||||
|                         ViewMatchers.isDisplayed() | ||||
|                     ) | ||||
|                         ViewMatchers.isDisplayed(), | ||||
|                     ), | ||||
|                 ).perform(ViewActions.click()) | ||||
|                 onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(R.id.more_logout), ViewMatchers.withText("Logout"), | ||||
|                         ViewMatchers.withId(R.id.more_logout), | ||||
|                         ViewMatchers.withText("Logout"), | ||||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), | ||||
|                                 0 | ||||
|                                 0, | ||||
|                             ), | ||||
|                             6 | ||||
|                         ) | ||||
|                     ) | ||||
|                             6, | ||||
|                         ), | ||||
|                     ), | ||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|                 onView( | ||||
|                     Matchers.allOf( | ||||
|                         ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), | ||||
|                         ViewMatchers.withId(android.R.id.button1), | ||||
|                         ViewMatchers.withText("Yes"), | ||||
|                         childAtPosition( | ||||
|                             childAtPosition( | ||||
|                                 ViewMatchers.withId(R.id.buttonPanel), | ||||
|                                 0 | ||||
|                                 0, | ||||
|                             ), | ||||
|                             3 | ||||
|                         ) | ||||
|                     ) | ||||
|                             3, | ||||
|                         ), | ||||
|                     ), | ||||
|                 ).perform(ViewActions.scrollTo(), ViewActions.click()) | ||||
|                 sleep(5000) | ||||
|             } catch (ignored: NoMatchingViewException) { | ||||
|  | @ -124,9 +128,9 @@ class UITestHelper { | |||
|         } | ||||
| 
 | ||||
|         fun childAtPosition( | ||||
|             parentMatcher: Matcher<View>, position: Int | ||||
|             parentMatcher: Matcher<View>, | ||||
|             position: Int, | ||||
|         ): Matcher<View> { | ||||
| 
 | ||||
|             return object : TypeSafeMatcher<View>() { | ||||
|                 override fun describeTo(description: Description) { | ||||
|                     description.appendText("Child at position $position in parent ") | ||||
|  | @ -135,8 +139,9 @@ class UITestHelper { | |||
| 
 | ||||
|                 public override fun matchesSafely(view: View): Boolean { | ||||
|                     val parent = view.parent | ||||
|                     return parent is ViewGroup && parentMatcher.matches(parent) | ||||
|                             && view == parent.getChildAt(position) | ||||
|                     return parent is ViewGroup && | ||||
|                         parentMatcher.matches(parent) && | ||||
|                         view == parent.getChildAt(position) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | @ -154,14 +159,18 @@ class UITestHelper { | |||
|             val username = BuildConfig.TEST_USERNAME | ||||
|             if (StringUtils.isEmpty(username) || username == "null") { | ||||
|                 throw NotImplementedError("Configure your beta account's username") | ||||
|             } else return username | ||||
|             } else { | ||||
|                 return username | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         private fun getTestUserPassword(): String { | ||||
|             val password = BuildConfig.TEST_PASSWORD | ||||
|             if (StringUtils.isEmpty(password) || password == "null") { | ||||
|                 throw NotImplementedError("Configure your beta account's password") | ||||
|             } else return password | ||||
|             } else { | ||||
|                 return password | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) { | ||||
|  | @ -174,6 +183,7 @@ class UITestHelper { | |||
|         fun <T> first(matcher: Matcher<T>): Matcher<T>? { | ||||
|             return object : BaseMatcher<T>() { | ||||
|                 var isFirst = true | ||||
| 
 | ||||
|                 override fun matches(item: Any): Boolean { | ||||
|                     if (isFirst && matcher.matches(item)) { | ||||
|                         isFirst = false | ||||
|  | @ -188,4 +198,4 @@ class UITestHelper { | |||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -28,7 +28,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class UploadCancelledTest { | ||||
| 
 | ||||
|     @Rule | ||||
|     @JvmField | ||||
|     var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) | ||||
|  | @ -37,7 +36,7 @@ class UploadCancelledTest { | |||
|     @JvmField | ||||
|     var mGrantPermissionRule: GrantPermissionRule = | ||||
|         GrantPermissionRule.grant( | ||||
|             "android.permission.WRITE_EXTERNAL_STORAGE" | ||||
|             "android.permission.WRITE_EXTERNAL_STORAGE", | ||||
|         ) | ||||
| 
 | ||||
|     private val device: UiDevice = | ||||
|  | @ -48,14 +47,14 @@ class UploadCancelledTest { | |||
|         try { | ||||
|             Intents.init() | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|         device.unfreezeRotation() | ||||
|         device.setOrientationNatural() | ||||
|         device.freezeRotation() | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|         Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|         Intents | ||||
|             .intending(CoreMatchers.not(IntentMatchers.isInternal())) | ||||
|             .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) | ||||
|     } | ||||
| 
 | ||||
|  | @ -64,130 +63,137 @@ class UploadCancelledTest { | |||
|         try { | ||||
|             Intents.release() | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     fun uploadCancelledAfterLocationPickedTest() { | ||||
| 
 | ||||
|         val bottomNavigationItemView = onView( | ||||
|             allOf( | ||||
|                 childAtPosition( | ||||
|         val bottomNavigationItemView = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.fragment_main_nav_tab_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.fragment_main_nav_tab_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         1, | ||||
|                     ), | ||||
|                     1 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         bottomNavigationItemView.perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(12000) | ||||
| 
 | ||||
|         val actionMenuItemView = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.list_sheet), | ||||
|                 childAtPosition( | ||||
|         val actionMenuItemView = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.list_sheet), | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.toolbar), | ||||
|                         1 | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.toolbar), | ||||
|                             1, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         actionMenuItemView.perform(click()) | ||||
| 
 | ||||
|         val recyclerView = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.rv_nearby_list), | ||||
|         val recyclerView = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.rv_nearby_list), | ||||
|                 ), | ||||
|             ) | ||||
|         ) | ||||
|         recyclerView.perform( | ||||
|             RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( | ||||
|                 0, | ||||
|                 click() | ||||
|             ) | ||||
|                 click(), | ||||
|             ), | ||||
|         ) | ||||
| 
 | ||||
|         val linearLayout3 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.cameraButton), | ||||
|                 childAtPosition( | ||||
|                     allOf( | ||||
|                         withId(R.id.nearby_button_layout), | ||||
|         val linearLayout3 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.cameraButton), | ||||
|                     childAtPosition( | ||||
|                         allOf( | ||||
|                             withId(R.id.nearby_button_layout), | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         linearLayout3.perform(click()) | ||||
| 
 | ||||
|         val pasteSensitiveTextInputEditText = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.caption_item_edit_text), | ||||
|                 childAtPosition( | ||||
|         val pasteSensitiveTextInputEditText = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.caption_item_edit_text), | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.caption_item_edit_text_input_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.caption_item_edit_text_input_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) | ||||
| 
 | ||||
|         val pasteSensitiveTextInputEditText2 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.description_item_edit_text), | ||||
|                 childAtPosition( | ||||
|         val pasteSensitiveTextInputEditText2 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.description_item_edit_text), | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.description_item_edit_text_input_layout), | ||||
|                         0 | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.description_item_edit_text_input_layout), | ||||
|                             0, | ||||
|                         ), | ||||
|                         0, | ||||
|                     ), | ||||
|                     0 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) | ||||
| 
 | ||||
|         val appCompatButton2 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.btn_next), | ||||
|                 childAtPosition( | ||||
|         val appCompatButton2 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.btn_next), | ||||
|                     childAtPosition( | ||||
|                         withId(R.id.ll_container_media_detail), | ||||
|                         2 | ||||
|                         childAtPosition( | ||||
|                             withId(R.id.ll_container_media_detail), | ||||
|                             2, | ||||
|                         ), | ||||
|                         1, | ||||
|                     ), | ||||
|                     1 | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|                 isDisplayed() | ||||
|             ) | ||||
|         ) | ||||
|         appCompatButton2.perform(click()) | ||||
| 
 | ||||
|         val appCompatButton3 = onView( | ||||
|             allOf( | ||||
|                 withId(android.R.id.button1), | ||||
|         val appCompatButton3 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(android.R.id.button1), | ||||
|                 ), | ||||
|             ) | ||||
|         ) | ||||
|         appCompatButton3.perform(scrollTo(), click()) | ||||
| 
 | ||||
|         Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) | ||||
| 
 | ||||
|         val floatingActionButton3 = onView( | ||||
|             allOf( | ||||
|                 withId(R.id.location_chosen_button), | ||||
|                 isDisplayed() | ||||
|         val floatingActionButton3 = | ||||
|             onView( | ||||
|                 allOf( | ||||
|                     withId(R.id.location_chosen_button), | ||||
|                     isDisplayed(), | ||||
|                 ), | ||||
|             ) | ||||
|         ) | ||||
|         UITestHelper.sleep(2000) | ||||
|         floatingActionButton3.perform(click()) | ||||
|     } | ||||
|  |  | |||
|  | @ -42,8 +42,11 @@ import java.util.* | |||
| @RunWith(AndroidJUnit4::class) | ||||
| class UploadTest { | ||||
|     @get:Rule | ||||
|     var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE, | ||||
|             Manifest.permission.ACCESS_FINE_LOCATION)!! | ||||
|     var permissionRule = | ||||
|         GrantPermissionRule.grant( | ||||
|             Manifest.permission.WRITE_EXTERNAL_STORAGE, | ||||
|             Manifest.permission.ACCESS_FINE_LOCATION, | ||||
|         )!! | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule = ActivityTestRule(LoginActivity::class.java) | ||||
|  | @ -61,7 +64,6 @@ class UploadTest { | |||
|         try { | ||||
|             Intents.init() | ||||
|         } catch (ex: IllegalStateException) { | ||||
| 
 | ||||
|         } | ||||
|         UITestHelper.loginUser() | ||||
|         UITestHelper.skipWelcome() | ||||
|  | @ -94,14 +96,13 @@ class UploadTest { | |||
|         dismissWarning("Yes") | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|                 .perform(replaceText(commonsFileName)) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.description_item_edit_text))) | ||||
|                 .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|             .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(5000) | ||||
|         dismissWarning("Yes") | ||||
|  | @ -109,29 +110,30 @@ class UploadTest { | |||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.et_search))) | ||||
|                 .perform(replaceText("Uploaded with Mobile/Android Tests")) | ||||
|             .perform(replaceText("Uploaded with Mobile/Android Tests")) | ||||
| 
 | ||||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         try { | ||||
|             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) | ||||
|                     .perform(click()) | ||||
|                 .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         dismissWarning("Yes, Submit") | ||||
| 
 | ||||
|         UITestHelper.sleep(500) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_submit))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(10000) | ||||
| 
 | ||||
|         val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|         val fileUrl = | ||||
|             "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|                 commonsFileName.replace(' ', '_') + ".jpg" | ||||
|         Timber.i("File should be uploaded to $fileUrl") | ||||
|     } | ||||
|  | @ -139,8 +141,8 @@ class UploadTest { | |||
|     private fun dismissWarning(warningText: String) { | ||||
|         try { | ||||
|             onView(withText(warningText)) | ||||
|                     .check(matches(isDisplayed())) | ||||
|                     .perform(click()) | ||||
|                 .check(matches(isDisplayed())) | ||||
|                 .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
|     } | ||||
|  | @ -167,10 +169,10 @@ class UploadTest { | |||
|         dismissWarning("Yes") | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|                 .perform(replaceText(commonsFileName)) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(10000) | ||||
|         dismissWarning("Yes") | ||||
|  | @ -178,29 +180,30 @@ class UploadTest { | |||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.et_search))) | ||||
|                 .perform(replaceText("Test")) | ||||
|             .perform(replaceText("Test")) | ||||
| 
 | ||||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         try { | ||||
|             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) | ||||
|                     .perform(click()) | ||||
|                 .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         dismissWarning("Yes, Submit") | ||||
| 
 | ||||
|         UITestHelper.sleep(500) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_submit))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(10000) | ||||
| 
 | ||||
|         val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|         val fileUrl = | ||||
|             "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|                 commonsFileName.replace(' ', '_') + ".jpg" | ||||
|         Timber.i("File should be uploaded to $fileUrl") | ||||
|     } | ||||
|  | @ -227,23 +230,29 @@ class UploadTest { | |||
|         dismissWarningDialog() | ||||
| 
 | ||||
|         onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) | ||||
|                 .perform(replaceText(commonsFileName)) | ||||
|             .perform(replaceText(commonsFileName)) | ||||
| 
 | ||||
|         onView(withId(R.id.rv_descriptions)).perform( | ||||
|                 RecyclerViewActions | ||||
|                         .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(0, | ||||
|                                 MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"))) | ||||
|             RecyclerViewActions | ||||
|                 .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>( | ||||
|                     0, | ||||
|                     MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"), | ||||
|                 ), | ||||
|         ) | ||||
| 
 | ||||
|         onView(withId(R.id.btn_add)) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         onView(withId(R.id.rv_descriptions)).perform( | ||||
|                 RecyclerViewActions | ||||
|                         .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(1, | ||||
|                                 MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"))) | ||||
|             RecyclerViewActions | ||||
|                 .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>( | ||||
|                     1, | ||||
|                     MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"), | ||||
|                 ), | ||||
|         ) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(5000) | ||||
|         dismissWarning("Yes") | ||||
|  | @ -251,29 +260,30 @@ class UploadTest { | |||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.et_search))) | ||||
|                 .perform(replaceText("Test")) | ||||
|             .perform(replaceText("Test")) | ||||
| 
 | ||||
|         UITestHelper.sleep(3000) | ||||
| 
 | ||||
|         try { | ||||
|             onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) | ||||
|                     .perform(click()) | ||||
|                 .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_next))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         dismissWarning("Yes, Submit") | ||||
| 
 | ||||
|         UITestHelper.sleep(500) | ||||
| 
 | ||||
|         onView(allOf(isDisplayed(), withId(R.id.btn_submit))) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         UITestHelper.sleep(10000) | ||||
| 
 | ||||
|         val fileUrl = "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|         val fileUrl = | ||||
|             "https://commons.wikimedia.beta.wmflabs.org/wiki/File:" + | ||||
|                 commonsFileName.replace(' ', '_') + ".jpg" | ||||
|         Timber.i("File should be uploaded to $fileUrl") | ||||
|     } | ||||
|  | @ -306,7 +316,6 @@ class UploadTest { | |||
|             } catch (e: IOException) { | ||||
|                 e.printStackTrace() | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -328,8 +337,8 @@ class UploadTest { | |||
|     private fun dismissWarningDialog() { | ||||
|         try { | ||||
|             onView(withText("Yes")) | ||||
|                     .check(matches(isDisplayed())) | ||||
|                     .perform(click()) | ||||
|                 .check(matches(isDisplayed())) | ||||
|                 .perform(click()) | ||||
|         } catch (ignored: NoMatchingViewException) { | ||||
|         } | ||||
|     } | ||||
|  | @ -337,10 +346,10 @@ class UploadTest { | |||
|     private fun openGallery() { | ||||
|         // Open FAB | ||||
|         onView(allOf<View>(withId(R.id.fab_plus), isDisplayed())) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
| 
 | ||||
|         // Click gallery | ||||
|         onView(allOf<View>(withId(R.id.fab_gallery), isDisplayed())) | ||||
|                 .perform(click()) | ||||
|             .perform(click()) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -3,7 +3,6 @@ package fr.free.nrw.commons | |||
| import androidx.test.espresso.Espresso.onView | ||||
| import androidx.test.espresso.action.ViewActions | ||||
| import androidx.test.espresso.assertion.ViewAssertions.matches | ||||
| import androidx.test.espresso.matcher.ViewMatchers | ||||
| import androidx.test.espresso.matcher.ViewMatchers.isDisplayed | ||||
| import androidx.test.espresso.matcher.ViewMatchers.withId | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||
|  | @ -22,7 +21,6 @@ import org.junit.runner.RunWith | |||
| @LargeTest | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class WelcomeActivityTest { | ||||
| 
 | ||||
|     @get:Rule | ||||
|     var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) | ||||
| 
 | ||||
|  | @ -130,4 +128,4 @@ class WelcomeActivityTest { | |||
|     fun orientationChange() { | ||||
|         UITestHelper.changeOrientation(activityRule) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -11,7 +11,6 @@ import org.junit.runner.RunWith | |||
| 
 | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class PasteSensitiveTextInputEditTextTest { | ||||
| 
 | ||||
|     private var context: Context? = null | ||||
|     private var textView: PasteSensitiveTextInputEditText? = null | ||||
| 
 | ||||
|  | @ -23,9 +22,13 @@ class PasteSensitiveTextInputEditTextTest { | |||
| 
 | ||||
|     // this test has no real value, just % for test code coverage | ||||
|     @Test | ||||
|     fun extractFormattingAttributeSet(){ | ||||
|         val methodExtractFormattingAttribute = textView!!.javaClass.getDeclaredMethod( | ||||
|             "extractFormattingAttribute", Context::class.java, AttributeSet::class.java) | ||||
|     fun extractFormattingAttributeSet() { | ||||
|         val methodExtractFormattingAttribute = | ||||
|             textView!!.javaClass.getDeclaredMethod( | ||||
|                 "extractFormattingAttribute", | ||||
|                 Context::class.java, | ||||
|                 AttributeSet::class.java, | ||||
|             ) | ||||
|         methodExtractFormattingAttribute.isAccessible = true | ||||
|         methodExtractFormattingAttribute.invoke(textView, context, null) | ||||
|     } | ||||
|  | @ -40,4 +43,4 @@ class PasteSensitiveTextInputEditTextTest { | |||
|         textView!!.setFormattingAllowed(false) | ||||
|         Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView)) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -9,56 +9,58 @@ import org.hamcrest.Matcher | |||
| 
 | ||||
| class MyViewAction { | ||||
|     companion object { | ||||
|         fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction { | ||||
|             return object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? { | ||||
|                     return null | ||||
|                 } | ||||
|         fun typeTextInChildViewWithId( | ||||
|             id: Int, | ||||
|             textToBeTyped: String, | ||||
|         ): ViewAction = | ||||
|             object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? = null | ||||
| 
 | ||||
|                 override fun getDescription(): String { | ||||
|                     return "Click on a child view with specified id." | ||||
|                 } | ||||
|                 override fun getDescription(): String = "Click on a child view with specified id." | ||||
| 
 | ||||
|                 override fun perform(uiController: UiController, view: View) { | ||||
|                 override fun perform( | ||||
|                     uiController: UiController, | ||||
|                     view: View, | ||||
|                 ) { | ||||
|                     val v = view.findViewById<View>(id) as EditText | ||||
|                     v.setText(textToBeTyped) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction { | ||||
|             return object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? { | ||||
|                     return null | ||||
|                 } | ||||
|         fun selectSpinnerItemInChildViewWithId( | ||||
|             id: Int, | ||||
|             position: Int, | ||||
|         ): ViewAction = | ||||
|             object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? = null | ||||
| 
 | ||||
|                 override fun getDescription(): String { | ||||
|                     return "Click on a child view with specified id." | ||||
|                 } | ||||
|                 override fun getDescription(): String = "Click on a child view with specified id." | ||||
| 
 | ||||
|                 override fun perform(uiController: UiController, view: View) { | ||||
|                 override fun perform( | ||||
|                     uiController: UiController, | ||||
|                     view: View, | ||||
|                 ) { | ||||
|                     val v = view.findViewById<View>(id) as AppCompatSpinner | ||||
|                     v.setSelection(position) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         fun clickItemWithId(id: Int, position: Int): ViewAction { | ||||
|             return object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? { | ||||
|                     return null | ||||
|                 } | ||||
|         fun clickItemWithId( | ||||
|             id: Int, | ||||
|             position: Int, | ||||
|         ): ViewAction = | ||||
|             object : ViewAction { | ||||
|                 override fun getConstraints(): Matcher<View>? = null | ||||
| 
 | ||||
|                 override fun getDescription(): String { | ||||
|                     return "Click on a child view with specified id." | ||||
|                 } | ||||
|                 override fun getDescription(): String = "Click on a child view with specified id." | ||||
| 
 | ||||
|                 override fun perform(uiController: UiController, view: View) { | ||||
|                 override fun perform( | ||||
|                     uiController: UiController, | ||||
|                     view: View, | ||||
|                 ) { | ||||
|                     val v = view.findViewById<View>(id) as View | ||||
|                     v.performClick() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -39,26 +39,25 @@ class BaseMarker { | |||
|     constructor() { | ||||
|     } | ||||
| 
 | ||||
|     fun fromResource(context: Context, drawableResId: Int) { | ||||
|     fun fromResource( | ||||
|         context: Context, | ||||
|         drawableResId: Int, | ||||
|     ) { | ||||
|         val drawable: Drawable = context.resources.getDrawable(drawableResId) | ||||
|         icon = if (drawable is BitmapDrawable) { | ||||
|             (drawable as BitmapDrawable).bitmap | ||||
|         } else { | ||||
|             val bitmap = Bitmap.createBitmap( | ||||
|                 drawable.intrinsicWidth, | ||||
|                 drawable.intrinsicHeight, Bitmap.Config.ARGB_8888 | ||||
|             ) | ||||
|             val canvas = Canvas(bitmap) | ||||
|             drawable.setBounds(0, 0, canvas.width, canvas.height) | ||||
|             drawable.draw(canvas) | ||||
|             bitmap | ||||
|         } | ||||
|         icon = | ||||
|             if (drawable is BitmapDrawable) { | ||||
|                 (drawable as BitmapDrawable).bitmap | ||||
|             } else { | ||||
|                 val bitmap = | ||||
|                     Bitmap.createBitmap( | ||||
|                         drawable.intrinsicWidth, | ||||
|                         drawable.intrinsicHeight, | ||||
|                         Bitmap.Config.ARGB_8888, | ||||
|                     ) | ||||
|                 val canvas = Canvas(bitmap) | ||||
|                 drawable.setBounds(0, 0, canvas.width, canvas.height) | ||||
|                 drawable.draw(canvas) | ||||
|                 bitmap | ||||
|             } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,9 +10,10 @@ object BetaConstants { | |||
|      * production server where beta server does not work | ||||
|      */ | ||||
|     const val COMMONS_URL = "https://commons.wikimedia.org/" | ||||
| 
 | ||||
|     /** | ||||
|      * Commons production's depicts property which is used in beta for some specific GET calls on | ||||
|      * production server where beta server does not work | ||||
|      */ | ||||
|     const val DEPICTS_PROPERTY = "P180" | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -3,31 +3,31 @@ package fr.free.nrw.commons | |||
| import android.os.Parcel | ||||
| import android.os.Parcelable | ||||
| 
 | ||||
| class CameraPosition(val latitude: Double, val longitude: Double, val zoom: Double) : Parcelable { | ||||
| 
 | ||||
| class CameraPosition( | ||||
|     val latitude: Double, | ||||
|     val longitude: Double, | ||||
|     val zoom: Double, | ||||
| ) : Parcelable { | ||||
|     constructor(parcel: Parcel) : this( | ||||
|         parcel.readDouble(), | ||||
|         parcel.readDouble(), | ||||
|         parcel.readDouble() | ||||
|         parcel.readDouble(), | ||||
|     ) | ||||
| 
 | ||||
|     override fun writeToParcel(parcel: Parcel, flags: Int) { | ||||
|     override fun writeToParcel( | ||||
|         parcel: Parcel, | ||||
|         flags: Int, | ||||
|     ) { | ||||
|         parcel.writeDouble(latitude) | ||||
|         parcel.writeDouble(longitude) | ||||
|         parcel.writeDouble(zoom) | ||||
|     } | ||||
| 
 | ||||
|     override fun describeContents(): Int { | ||||
|         return 0 | ||||
|     } | ||||
|     override fun describeContents(): Int = 0 | ||||
| 
 | ||||
|     companion object CREATOR : Parcelable.Creator<CameraPosition> { | ||||
|         override fun createFromParcel(parcel: Parcel): CameraPosition { | ||||
|             return CameraPosition(parcel) | ||||
|         } | ||||
|         override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel) | ||||
| 
 | ||||
|         override fun newArray(size: Int): Array<CameraPosition?> { | ||||
|             return arrayOfNulls(size) | ||||
|         } | ||||
|         override fun newArray(size: Int): Array<CameraPosition?> = arrayOfNulls(size) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -2,8 +2,8 @@ package fr.free.nrw.commons | |||
| 
 | ||||
| import android.os.Parcelable | ||||
| import fr.free.nrw.commons.location.LatLng | ||||
| import kotlinx.parcelize.Parcelize | ||||
| import fr.free.nrw.commons.wikidata.model.page.PageTitle | ||||
| import kotlinx.parcelize.Parcelize | ||||
| import java.util.* | ||||
| 
 | ||||
| @Parcelize | ||||
|  | @ -14,7 +14,6 @@ class Media constructor( | |||
|      */ | ||||
|     var pageId: String = UUID.randomUUID().toString(), | ||||
|     var thumbUrl: String? = null, | ||||
| 
 | ||||
|     /** | ||||
|      * Gets image URL | ||||
|      * @return Image URL | ||||
|  | @ -35,7 +34,6 @@ class Media constructor( | |||
|      * @param fallbackDescription the new description of the file | ||||
|      */ | ||||
|     var fallbackDescription: String? = null, | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the upload date of the file. | ||||
|      * Can be null. | ||||
|  | @ -62,9 +60,7 @@ class Media constructor( | |||
|      * @param author creator name as a string | ||||
|      */ | ||||
|     var author: String? = null, | ||||
| 
 | ||||
|     var user:String?=null, | ||||
| 
 | ||||
|     var user: String? = null, | ||||
|     /** | ||||
|      * Gets the categories the file falls under. | ||||
|      * @return file categories as an ArrayList of Strings | ||||
|  | @ -83,23 +79,23 @@ class Media constructor( | |||
|      * Stores the mapping of category title to hidden attribute | ||||
|      * Example: "Mountains" => false, "CC-BY-SA-2.0" => true | ||||
|      */ | ||||
|     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap() | ||||
|     var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(), | ||||
| ) : Parcelable { | ||||
| 
 | ||||
|     constructor( | ||||
|         captions: Map<String, String>, | ||||
|         categories: List<String>?, | ||||
|         filename: String?, | ||||
|         fallbackDescription: String?, | ||||
|         author: String?, user:String? | ||||
|         author: String?, | ||||
|         user: String?, | ||||
|     ) : this( | ||||
|         filename = filename, | ||||
|         fallbackDescription = fallbackDescription, | ||||
|         dateUploaded = Date(), | ||||
|         author = author, | ||||
|         user=user, | ||||
|         user = user, | ||||
|         categories = categories, | ||||
|         captions = captions | ||||
|         captions = captions, | ||||
|     ) | ||||
| 
 | ||||
|     /** | ||||
|  | @ -108,10 +104,11 @@ class Media constructor( | |||
|      */ | ||||
|     val displayTitle: String | ||||
|         get() = | ||||
|             if (filename != null) | ||||
|             if (filename != null) { | ||||
|                 pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") | ||||
|             else | ||||
|             } else { | ||||
|                 "" | ||||
|             } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets file page title | ||||
|  | @ -127,9 +124,10 @@ class Media constructor( | |||
|         get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) | ||||
| 
 | ||||
|     val mostRelevantCaption: String | ||||
|         get() = captions[Locale.getDefault().language] | ||||
|             ?: captions.values.firstOrNull() | ||||
|             ?: displayTitle | ||||
|         get() = | ||||
|             captions[Locale.getDefault().language] | ||||
|                 ?: captions.values.firstOrNull() | ||||
|                 ?: displayTitle | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the categories the file falls under. | ||||
|  | @ -138,6 +136,8 @@ class Media constructor( | |||
|     var addedCategories: List<String>? = null | ||||
|         // TODO added categories should be removed. It is added for a short fix. On category update, | ||||
|         //  categories should be re-fetched instead | ||||
|         get() = field                     // getter | ||||
|         set(value) { field = value }      // setter | ||||
|         get() = field // getter | ||||
|         set(value) { | ||||
|             field = value | ||||
|         } // setter | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| package fr.free.nrw.commons | ||||
| 
 | ||||
| import androidx.core.text.HtmlCompat | ||||
| import fr.free.nrw.commons.media.PAGE_ID_PREFIX | ||||
| import fr.free.nrw.commons.media.IdAndCaptions | ||||
| import fr.free.nrw.commons.media.MediaClient | ||||
| import fr.free.nrw.commons.media.PAGE_ID_PREFIX | ||||
| import io.reactivex.Single | ||||
| import timber.log.Timber | ||||
| import javax.inject.Inject | ||||
|  | @ -17,42 +17,46 @@ import javax.inject.Singleton | |||
|  * to the media and may change due to editing. | ||||
|  */ | ||||
| @Singleton | ||||
| class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) { | ||||
| class MediaDataExtractor | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val mediaClient: MediaClient, | ||||
|     ) { | ||||
|         fun fetchDepictionIdsAndLabels(media: Media) = | ||||
|             mediaClient | ||||
|                 .getEntities(media.depictionIds) | ||||
|                 .map { | ||||
|                     it | ||||
|                         .entities() | ||||
|                         .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } | ||||
|                 }.map { it.map { (key, value) -> IdAndCaptions(key, value) } } | ||||
|                 .onErrorReturn { emptyList() } | ||||
| 
 | ||||
|     fun fetchDepictionIdsAndLabels(media: Media) = | ||||
|         mediaClient.getEntities(media.depictionIds) | ||||
|             .map { | ||||
|                 it.entities() | ||||
|                     .mapValues { entry -> entry.value.labels().mapValues { it.value.value() } } | ||||
|             } | ||||
|             .map { it.map { (key, value) -> IdAndCaptions(key, value) } } | ||||
|             .onErrorReturn { emptyList() } | ||||
|         fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) | ||||
| 
 | ||||
|     fun checkDeletionRequestExists(media: Media) = | ||||
|         mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) | ||||
|         fun fetchDiscussion(media: Media) = | ||||
|             mediaClient | ||||
|                 .getPageHtml(media.filename!!.replace("File", "File talk")) | ||||
|                 .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } | ||||
|                 .onErrorReturn { | ||||
|                     Timber.d("Error occurred while fetching discussion") | ||||
|                     "" | ||||
|                 } | ||||
| 
 | ||||
|     fun fetchDiscussion(media: Media) = | ||||
|         mediaClient.getPageHtml(media.filename!!.replace("File", "File talk")) | ||||
|             .map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } | ||||
|             .onErrorReturn { | ||||
|                 Timber.d("Error occurred while fetching discussion") | ||||
|                 "" | ||||
|             } | ||||
|         fun refresh(media: Media): Single<Media> = | ||||
|             Single.ambArray( | ||||
|                 mediaClient | ||||
|                     .getMediaById(PAGE_ID_PREFIX + media.pageId) | ||||
|                     .onErrorResumeNext { Single.never() }, | ||||
|                 mediaClient | ||||
|                     .getMediaSuppressingErrors(media.filename) | ||||
|                     .onErrorResumeNext { Single.never() }, | ||||
|             ) | ||||
| 
 | ||||
|     fun refresh(media: Media): Single<Media> { | ||||
|         return Single.ambArray( | ||||
|             mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId) | ||||
|                 .onErrorResumeNext { Single.never() }, | ||||
|             mediaClient.getMediaSuppressingErrors(media.filename) | ||||
|                 .onErrorResumeNext { Single.never() } | ||||
|         ) | ||||
|         fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title) | ||||
| 
 | ||||
|         /** | ||||
|          * Fetches wikitext from mediaClient | ||||
|          */ | ||||
|         fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title) | ||||
|     } | ||||
| 
 | ||||
|     fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title); | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches wikitext from mediaClient | ||||
|      */ | ||||
|     fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title); | ||||
| } | ||||
|  |  | |||
|  | @ -1,10 +1,9 @@ | |||
| package fr.free.nrw.commons.actions | ||||
| 
 | ||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||
| import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | ||||
| import io.reactivex.Observable | ||||
| import io.reactivex.Single | ||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||
| import timber.log.Timber | ||||
| 
 | ||||
| /** | ||||
|  * This class acts as a Client to facilitate wiki page editing | ||||
|  | @ -15,9 +14,8 @@ import timber.log.Timber | |||
|  */ | ||||
| class PageEditClient( | ||||
|     private val csrfTokenClient: CsrfTokenClient, | ||||
|     private val pageEditInterface: PageEditInterface | ||||
|     private val pageEditInterface: PageEditInterface, | ||||
| ) { | ||||
| 
 | ||||
|     /** | ||||
|      * Replace the content of a wiki page | ||||
|      * @param pageTitle   Title of the page to edit | ||||
|  | @ -25,12 +23,17 @@ class PageEditClient( | |||
|      * @param summary     Edit summary | ||||
|      * @return whether the edit was successful | ||||
|      */ | ||||
|     fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) | ||||
|     fun edit( | ||||
|         pageTitle: String, | ||||
|         text: String, | ||||
|         summary: String, | ||||
|     ): Observable<Boolean> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) | ||||
|                 .map { editResponse -> | ||||
|                         editResponse.edit()!!.editSucceeded() | ||||
|                     } | ||||
|                     editResponse.edit()!!.editSucceeded() | ||||
|                 } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|  | @ -38,7 +41,6 @@ class PageEditClient( | |||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates a new page with the given title, text, and summary. | ||||
|  | @ -49,20 +51,25 @@ class PageEditClient( | |||
|      * @return An observable that emits true if the page creation succeeded, false otherwise. | ||||
|      * @throws InvalidLoginTokenException If an invalid login token is encountered during the process. | ||||
|      */ | ||||
|     fun postCreate(pageTitle: String, text: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postCreate( | ||||
|                 pageTitle, | ||||
|                 summary, | ||||
|                 text, | ||||
|                 "text/x-wiki", | ||||
|                 "wikitext", | ||||
|                 true, | ||||
|                 true, | ||||
|                 csrfTokenClient.getTokenBlocking() | ||||
|             ).map { editResponse -> | ||||
|                 editResponse.edit()!!.editSucceeded() | ||||
|             } | ||||
|     fun postCreate( | ||||
|         pageTitle: String, | ||||
|         text: String, | ||||
|         summary: String, | ||||
|     ): Observable<Boolean> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postCreate( | ||||
|                     pageTitle, | ||||
|                     summary, | ||||
|                     text, | ||||
|                     "text/x-wiki", | ||||
|                     "wikitext", | ||||
|                     true, | ||||
|                     true, | ||||
|                     csrfTokenClient.getTokenBlocking(), | ||||
|                 ).map { editResponse -> | ||||
|                     editResponse.edit()!!.editSucceeded() | ||||
|                 } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|  | @ -70,7 +77,6 @@ class PageEditClient( | |||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Append text to the end of a wiki page | ||||
|  | @ -79,9 +85,14 @@ class PageEditClient( | |||
|      * @param summary     Edit summary | ||||
|      * @return whether the edit was successful | ||||
|      */ | ||||
|     fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) | ||||
|     fun appendEdit( | ||||
|         pageTitle: String, | ||||
|         appendText: String, | ||||
|         summary: String, | ||||
|     ): Observable<Boolean> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) | ||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|  | @ -90,7 +101,6 @@ class PageEditClient( | |||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepend text to the beginning of a wiki page | ||||
|  | @ -99,9 +109,14 @@ class PageEditClient( | |||
|      * @param summary     Edit summary | ||||
|      * @return whether the edit was successful | ||||
|      */ | ||||
|     fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) | ||||
|     fun prependEdit( | ||||
|         pageTitle: String, | ||||
|         prependText: String, | ||||
|         summary: String, | ||||
|     ): Observable<Boolean> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) | ||||
|                 .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|  | @ -110,8 +125,6 @@ class PageEditClient( | |||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Appends a new section to the wiki page | ||||
|  | @ -121,9 +134,15 @@ class PageEditClient( | |||
|      * @param summary     Edit summary | ||||
|      * @return whether the edit was successful | ||||
|      */ | ||||
|     fun createNewSection(pageTitle: String, sectionTitle: String, sectionText: String, summary: String): Observable<Boolean> { | ||||
|         return try { | ||||
|             pageEditInterface.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking()) | ||||
|     fun createNewSection( | ||||
|         pageTitle: String, | ||||
|         sectionTitle: String, | ||||
|         sectionText: String, | ||||
|         summary: String, | ||||
|     ): Observable<Boolean> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking()) | ||||
|                 .map { editResponse -> editResponse.edit()!!.editSucceeded() } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|  | @ -132,8 +151,6 @@ class PageEditClient( | |||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Set new labels to Wikibase server of commons | ||||
|  | @ -143,12 +160,21 @@ class PageEditClient( | |||
|      * @param value label | ||||
|      * @return 1 when the edit was successful | ||||
|      */ | ||||
|     fun setCaptions(summary: String, title: String, | ||||
|                     language: String, value: String) : Observable<Int>{ | ||||
|         return try { | ||||
|             pageEditInterface.postCaptions(summary, title, language, | ||||
|                 value, csrfTokenClient.getTokenBlocking() | ||||
|             ).map { it.success } | ||||
|     fun setCaptions( | ||||
|         summary: String, | ||||
|         title: String, | ||||
|         language: String, | ||||
|         value: String, | ||||
|     ): Observable<Int> = | ||||
|         try { | ||||
|             pageEditInterface | ||||
|                 .postCaptions( | ||||
|                     summary, | ||||
|                     title, | ||||
|                     language, | ||||
|                     value, | ||||
|                     csrfTokenClient.getTokenBlocking(), | ||||
|                 ).map { it.success } | ||||
|         } catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 throw throwable | ||||
|  | @ -156,16 +182,20 @@ class PageEditClient( | |||
|                 Observable.just(0) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get whole WikiText of required file | ||||
|      * @param title : Name of the file | ||||
|      * @return Observable<MwQueryResult> | ||||
|      */ | ||||
|     fun getCurrentWikiText(title: String): Single<String?> { | ||||
|         return pageEditInterface.getWikiText(title).map { | ||||
|             it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content() | ||||
|     fun getCurrentWikiText(title: String): Single<String?> = | ||||
|         pageEditInterface.getWikiText(title).map { | ||||
|             it | ||||
|                 .query() | ||||
|                 ?.pages() | ||||
|                 ?.get(0) | ||||
|                 ?.revisions() | ||||
|                 ?.get(0) | ||||
|                 ?.content() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -3,9 +3,9 @@ package fr.free.nrw.commons.actions | |||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | ||||
| import fr.free.nrw.commons.wikidata.model.Entities | ||||
| import fr.free.nrw.commons.wikidata.model.edit.Edit | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.Observable | ||||
| import io.reactivex.Single | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import retrofit2.http.* | ||||
| 
 | ||||
| /** | ||||
|  | @ -33,7 +33,7 @@ interface PageEditInterface { | |||
|         @Field("summary") summary: String, | ||||
|         @Field("text") text: String, | ||||
|         // NOTE: This csrf shold always be sent as the last field of form data | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -60,7 +60,7 @@ interface PageEditInterface { | |||
|         @Field("minor") minor: Boolean, | ||||
|         @Field("recreate") recreate: Boolean, | ||||
|         // NOTE: This csrf shold always be sent as the last field of form data | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -79,7 +79,7 @@ interface PageEditInterface { | |||
|         @Field("title") title: String, | ||||
|         @Field("summary") summary: String, | ||||
|         @Field("appendtext") appendText: String, | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -98,7 +98,7 @@ interface PageEditInterface { | |||
|         @Field("title") title: String, | ||||
|         @Field("summary") summary: String, | ||||
|         @Field("prependtext") prependText: String, | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|  | @ -109,7 +109,7 @@ interface PageEditInterface { | |||
|         @Field("summary") summary: String, | ||||
|         @Field("sectiontitle") sectionTitle: String, | ||||
|         @Field("text") sectionText: String, | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Edit> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|  | @ -120,7 +120,7 @@ interface PageEditInterface { | |||
|         @Field("title") title: String, | ||||
|         @Field("language") language: String, | ||||
|         @Field("value") value: String, | ||||
|         @Field("token") token: String | ||||
|         @Field("token") token: String, | ||||
|     ): Observable<Entities> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -130,6 +130,6 @@ interface PageEditInterface { | |||
|      */ | ||||
|     @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") | ||||
|     fun getWikiText( | ||||
|         @Query("titles") title: String | ||||
|         @Query("titles") title: String, | ||||
|     ): Single<MwQueryResponse?> | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -1,11 +1,10 @@ | |||
| package fr.free.nrw.commons.actions | ||||
| 
 | ||||
| import fr.free.nrw.commons.CommonsApplication | ||||
| import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF | ||||
| import io.reactivex.Observable | ||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||
| import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException | ||||
| import fr.free.nrw.commons.auth.login.LoginFailedException | ||||
| import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF | ||||
| import io.reactivex.Observable | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Named | ||||
| import javax.inject.Singleton | ||||
|  | @ -15,34 +14,33 @@ import javax.inject.Singleton | |||
|  * Thanks are used by a user to show gratitude to another user for their contributions | ||||
|  */ | ||||
| @Singleton | ||||
| class ThanksClient @Inject constructor( | ||||
|     @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, | ||||
|     private val service: ThanksInterface | ||||
| ) { | ||||
|     /** | ||||
|      * Thanks a user for a particular revision | ||||
|      * @param revisionId The revision ID the user would like to thank someone for | ||||
|      * @return if thanks was successfully sent to intended recipient | ||||
|      */ | ||||
|     fun thank(revisionId: Long): Observable<Boolean> { | ||||
|         return try { | ||||
|             service.thank( | ||||
|                 revisionId.toString(),                      // Rev | ||||
|                 null,                                       // Log | ||||
|                 csrfTokenClient.getTokenBlocking(),              // Token | ||||
|                 CommonsApplication.getInstance().userAgent  // Source | ||||
|             ).map { | ||||
|                 mwThankPostResponse -> mwThankPostResponse.result?.success == 1 | ||||
| class ThanksClient | ||||
|     @Inject | ||||
|     constructor( | ||||
|         @param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, | ||||
|         private val service: ThanksInterface, | ||||
|     ) { | ||||
|         /** | ||||
|          * Thanks a user for a particular revision | ||||
|          * @param revisionId The revision ID the user would like to thank someone for | ||||
|          * @return if thanks was successfully sent to intended recipient | ||||
|          */ | ||||
|         fun thank(revisionId: Long): Observable<Boolean> = | ||||
|             try { | ||||
|                 service | ||||
|                     .thank( | ||||
|                         revisionId.toString(), // Rev | ||||
|                         null, // Log | ||||
|                         csrfTokenClient.getTokenBlocking(), // Token | ||||
|                         CommonsApplication.getInstance().userAgent, // Source | ||||
|                     ).map { mwThankPostResponse -> | ||||
|                         mwThankPostResponse.result?.success == 1 | ||||
|                     } | ||||
|             } catch (throwable: Throwable) { | ||||
|                 if (throwable is InvalidLoginTokenException) { | ||||
|                     Observable.error(throwable) | ||||
|                 } else { | ||||
|                     Observable.just(false) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (throwable: Throwable) { | ||||
|             if (throwable is InvalidLoginTokenException) { | ||||
|                 Observable.error(throwable) | ||||
|             } | ||||
|             else { | ||||
|                 Observable.just(false) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -19,6 +19,6 @@ interface ThanksInterface { | |||
|         @Field("rev") rev: String?, | ||||
|         @Field("log") log: String?, | ||||
|         @Field("token") token: String, | ||||
|         @Field("source") source: String? | ||||
|         @Field("source") source: String?, | ||||
|     ): Observable<MwThankPostResponse?> | ||||
| } | ||||
|  |  | |||
|  | @ -2,11 +2,11 @@ package fr.free.nrw.commons.auth.csrf | |||
| 
 | ||||
| import androidx.annotation.VisibleForTesting | ||||
| import fr.free.nrw.commons.auth.SessionManager | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import fr.free.nrw.commons.auth.login.LoginClient | ||||
| import fr.free.nrw.commons.auth.login.LoginCallback | ||||
| import fr.free.nrw.commons.auth.login.LoginClient | ||||
| import fr.free.nrw.commons.auth.login.LoginFailedException | ||||
| import fr.free.nrw.commons.auth.login.LoginResult | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import retrofit2.Call | ||||
| import retrofit2.Response | ||||
| import timber.log.Timber | ||||
|  | @ -17,12 +17,11 @@ class CsrfTokenClient( | |||
|     private val sessionManager: SessionManager, | ||||
|     private val csrfTokenInterface: CsrfTokenInterface, | ||||
|     private val loginClient: LoginClient, | ||||
|     private val logoutClient: LogoutClient | ||||
|     private val logoutClient: LogoutClient, | ||||
| ) { | ||||
|     private var retries = 0 | ||||
|     private var csrfTokenCall: Call<MwQueryResponse?>? = null | ||||
| 
 | ||||
| 
 | ||||
|     @Throws(Throwable::class) | ||||
|     fun getTokenBlocking(): String { | ||||
|         var token = "" | ||||
|  | @ -37,11 +36,20 @@ class CsrfTokenClient( | |||
|                 } | ||||
| 
 | ||||
|                 // Get CSRFToken response off the main thread. | ||||
|                 val response = newSingleThreadExecutor().submit(Callable { | ||||
|                     csrfTokenInterface.getCsrfTokenCall().execute() | ||||
|                 }).get() | ||||
|                 val response = | ||||
|                     newSingleThreadExecutor() | ||||
|                         .submit( | ||||
|                             Callable { | ||||
|                                 csrfTokenInterface.getCsrfTokenCall().execute() | ||||
|                             }, | ||||
|                         ).get() | ||||
| 
 | ||||
|                 if (response.body()?.query()?.csrfToken().isNullOrEmpty()) { | ||||
|                 if (response | ||||
|                         .body() | ||||
|                         ?.query() | ||||
|                         ?.csrfToken() | ||||
|                         .isNullOrEmpty() | ||||
|                 ) { | ||||
|                     continue | ||||
|                 } | ||||
| 
 | ||||
|  | @ -51,9 +59,8 @@ class CsrfTokenClient( | |||
|                 } | ||||
|                 break | ||||
|             } catch (e: LoginFailedException) { | ||||
|                throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|             } | ||||
|             catch (t: Throwable) { | ||||
|                 throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|             } catch (t: Throwable) { | ||||
|                 Timber.w(t) | ||||
|             } | ||||
|         } | ||||
|  | @ -65,45 +72,65 @@ class CsrfTokenClient( | |||
|     } | ||||
| 
 | ||||
|     @VisibleForTesting | ||||
|     fun request(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> = | ||||
|         requestToken(service, object : Callback { | ||||
|             override fun success(token: String?) { | ||||
|                 if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { | ||||
|                     retryWithLogin(cb) { | ||||
|                         InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|     fun request( | ||||
|         service: CsrfTokenInterface, | ||||
|         cb: Callback, | ||||
|     ): Call<MwQueryResponse?> = | ||||
|         requestToken( | ||||
|             service, | ||||
|             object : Callback { | ||||
|                 override fun success(token: String?) { | ||||
|                     if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { | ||||
|                         retryWithLogin(cb) { | ||||
|                             InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) | ||||
|                         } | ||||
|                     } else { | ||||
|                         cb.success(token) | ||||
|                     } | ||||
|                 } else { | ||||
|                     cb.success(token) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } | ||||
|                 override fun failure(caught: Throwable?) = retryWithLogin(cb) { caught } | ||||
| 
 | ||||
|             override fun twoFactorPrompt() = cb.twoFactorPrompt() | ||||
|         }) | ||||
|                 override fun twoFactorPrompt() = cb.twoFactorPrompt() | ||||
|             }, | ||||
|         ) | ||||
| 
 | ||||
|     @VisibleForTesting | ||||
|     fun requestToken(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> { | ||||
|     fun requestToken( | ||||
|         service: CsrfTokenInterface, | ||||
|         cb: Callback, | ||||
|     ): Call<MwQueryResponse?> { | ||||
|         val call = service.getCsrfTokenCall() | ||||
|         call.enqueue(object : retrofit2.Callback<MwQueryResponse?> { | ||||
|             override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { | ||||
|                 if (call.isCanceled) { | ||||
|                     return | ||||
|         call.enqueue( | ||||
|             object : retrofit2.Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.success(response.body()!!.query()!!.csrfToken()) | ||||
|                 } | ||||
|                 cb.success(response.body()!!.query()!!.csrfToken()) | ||||
|             } | ||||
| 
 | ||||
|             override fun onFailure(call: Call<MwQueryResponse?>, t: Throwable) { | ||||
|                 if (call.isCanceled) { | ||||
|                     return | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.failure(t) | ||||
|                 } | ||||
|                 cb.failure(t) | ||||
|             } | ||||
|         }) | ||||
|             }, | ||||
|         ) | ||||
|         return call | ||||
|     } | ||||
| 
 | ||||
|     private fun retryWithLogin(callback: Callback, caught: () -> Throwable?) { | ||||
|     private fun retryWithLogin( | ||||
|         callback: Callback, | ||||
|         caught: () -> Throwable?, | ||||
|     ) { | ||||
|         val userName = sessionManager.userName | ||||
|         val password = sessionManager.password | ||||
|         if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { | ||||
|  | @ -123,26 +150,31 @@ class CsrfTokenClient( | |||
|         username: String, | ||||
|         password: String, | ||||
|         callback: Callback, | ||||
|         retryCallback: () -> Unit | ||||
|     ) = loginClient.request(username, password, object : LoginCallback { | ||||
|         override fun success(loginResult: LoginResult) { | ||||
|             if (loginResult.pass) { | ||||
|                 sessionManager.updateAccount(loginResult) | ||||
|                 retryCallback() | ||||
|             } else { | ||||
|                 callback.failure(LoginFailedException(loginResult.message)) | ||||
|         retryCallback: () -> Unit, | ||||
|     ) = loginClient.request( | ||||
|         username, | ||||
|         password, | ||||
|         object : LoginCallback { | ||||
|             override fun success(loginResult: LoginResult) { | ||||
|                 if (loginResult.pass) { | ||||
|                     sessionManager.updateAccount(loginResult) | ||||
|                     retryCallback() | ||||
|                 } else { | ||||
|                     callback.failure(LoginFailedException(loginResult.message)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         override fun twoFactorPrompt(caught: Throwable, token: String?) = | ||||
|             callback.twoFactorPrompt() | ||||
|             override fun twoFactorPrompt( | ||||
|                 caught: Throwable, | ||||
|                 token: String?, | ||||
|             ) = callback.twoFactorPrompt() | ||||
| 
 | ||||
|         // Should not happen here, but call the callback just in case. | ||||
|         override fun passwordResetPrompt(token: String?) = | ||||
|             callback.failure(LoginFailedException("Logged in with temporary password.")) | ||||
|             // Should not happen here, but call the callback just in case. | ||||
|             override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password.")) | ||||
| 
 | ||||
|         override fun error(caught: Throwable) = callback.failure(caught) | ||||
|     }) | ||||
|             override fun error(caught: Throwable) = callback.failure(caught) | ||||
|         }, | ||||
|     ) | ||||
| 
 | ||||
|     private fun cancel() { | ||||
|         loginClient.cancel() | ||||
|  | @ -154,7 +186,9 @@ class CsrfTokenClient( | |||
| 
 | ||||
|     interface Callback { | ||||
|         fun success(token: String?) | ||||
| 
 | ||||
|         fun failure(caught: Throwable?) | ||||
| 
 | ||||
|         fun twoFactorPrompt() | ||||
|     } | ||||
| 
 | ||||
|  | @ -166,5 +200,7 @@ class CsrfTokenClient( | |||
|         const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." | ||||
|     } | ||||
| } | ||||
| class InvalidLoginTokenException(message: String) : Exception(message) | ||||
| 
 | ||||
| class InvalidLoginTokenException( | ||||
|     message: String, | ||||
| ) : Exception(message) | ||||
|  |  | |||
|  | @ -3,6 +3,10 @@ package fr.free.nrw.commons.auth.csrf | |||
| import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class LogoutClient @Inject constructor(private val store: CommonsCookieStorage) { | ||||
|     fun logout() = store.clear() | ||||
| } | ||||
| class LogoutClient | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val store: CommonsCookieStorage, | ||||
|     ) { | ||||
|         fun logout() = store.clear() | ||||
|     } | ||||
|  |  | |||
|  | @ -2,7 +2,13 @@ package fr.free.nrw.commons.auth.login | |||
| 
 | ||||
| interface LoginCallback { | ||||
|     fun success(loginResult: LoginResult) | ||||
|     fun twoFactorPrompt(caught: Throwable, token: String?) | ||||
| 
 | ||||
|     fun twoFactorPrompt( | ||||
|         caught: Throwable, | ||||
|         token: String?, | ||||
|     ) | ||||
| 
 | ||||
|     fun passwordResetPrompt(token: String?) | ||||
| 
 | ||||
|     fun error(caught: Throwable) | ||||
| } | ||||
|  |  | |||
|  | @ -4,9 +4,9 @@ import android.text.TextUtils | |||
| import fr.free.nrw.commons.auth.login.LoginResult.OAuthResult | ||||
| import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult | ||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.android.schedulers.AndroidSchedulers | ||||
| import io.reactivex.schedulers.Schedulers | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import retrofit2.Call | ||||
| import retrofit2.Callback | ||||
| import retrofit2.Response | ||||
|  | @ -16,7 +16,9 @@ import java.io.IOException | |||
| /** | ||||
|  * Responsible for making login related requests to the server. | ||||
|  */ | ||||
| class LoginClient(private val loginInterface: LoginInterface) { | ||||
| class LoginClient( | ||||
|     private val loginInterface: LoginInterface, | ||||
| ) { | ||||
|     private var tokenCall: Call<MwQueryResponse?>? = null | ||||
|     private var loginCall: Call<LoginResponse?>? = null | ||||
| 
 | ||||
|  | @ -30,80 +32,116 @@ class LoginClient(private val loginInterface: LoginInterface) { | |||
| 
 | ||||
|     private fun getLoginToken() = loginInterface.getLoginToken() | ||||
| 
 | ||||
|     fun request(userName: String, password: String, cb: LoginCallback) { | ||||
|     fun request( | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         cb: LoginCallback, | ||||
|     ) { | ||||
|         cancel() | ||||
| 
 | ||||
|         tokenCall = getLoginToken() | ||||
|         tokenCall!!.enqueue(object : Callback<MwQueryResponse?> { | ||||
|             override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { | ||||
|                 login( | ||||
|                     userName, password, null, null, response.body()!!.query()!!.loginToken(), | ||||
|                     userLanguage, cb | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|             override fun onFailure(call: Call<MwQueryResponse?>, caught: Throwable) { | ||||
|                 if (call.isCanceled) { | ||||
|                     return | ||||
|         tokenCall!!.enqueue( | ||||
|             object : Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) { | ||||
|                     login( | ||||
|                         userName, | ||||
|                         password, | ||||
|                         null, | ||||
|                         null, | ||||
|                         response.body()!!.query()!!.loginToken(), | ||||
|                         userLanguage, | ||||
|                         cb, | ||||
|                     ) | ||||
|                 } | ||||
|                 cb.error(caught) | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     caught: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.error(caught) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun login( | ||||
|         userName: String, password: String, retypedPassword: String?, twoFactorCode: String?, | ||||
|         loginToken: String?, userLanguage: String, cb: LoginCallback | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         retypedPassword: String?, | ||||
|         twoFactorCode: String?, | ||||
|         loginToken: String?, | ||||
|         userLanguage: String, | ||||
|         cb: LoginCallback, | ||||
|     ) { | ||||
|         this.userLanguage = userLanguage | ||||
| 
 | ||||
|         loginCall = if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { | ||||
|             loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|         } else { | ||||
|             loginInterface.postLogIn( | ||||
|                 userName, password, retypedPassword, twoFactorCode, loginToken, userLanguage, true | ||||
|             ) | ||||
|         } | ||||
|         loginCall = | ||||
|             if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { | ||||
|                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|             } else { | ||||
|                 loginInterface.postLogIn( | ||||
|                     userName, | ||||
|                     password, | ||||
|                     retypedPassword, | ||||
|                     twoFactorCode, | ||||
|                     loginToken, | ||||
|                     userLanguage, | ||||
|                     true, | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|         loginCall!!.enqueue(object : Callback<LoginResponse?> { | ||||
|             override fun onResponse( | ||||
|                 call: Call<LoginResponse?>, | ||||
|                 response: Response<LoginResponse?> | ||||
|             ) { | ||||
|                 val loginResult = response.body()?.toLoginResult(password) | ||||
|                 if (loginResult != null) { | ||||
|                     if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { | ||||
|                         // The server could do some transformations on user names, e.g. on some | ||||
|                         // wikis is uppercases the first letter. | ||||
|                         getExtendedInfo(loginResult.userName, loginResult, cb) | ||||
|                     } else if ("UI" == loginResult.status) { | ||||
|                         when (loginResult) { | ||||
|                             is OAuthResult -> cb.twoFactorPrompt( | ||||
|                                 LoginFailedException(loginResult.message), | ||||
|                                 loginToken | ||||
|                             ) | ||||
|         loginCall!!.enqueue( | ||||
|             object : Callback<LoginResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<LoginResponse?>, | ||||
|                     response: Response<LoginResponse?>, | ||||
|                 ) { | ||||
|                     val loginResult = response.body()?.toLoginResult(password) | ||||
|                     if (loginResult != null) { | ||||
|                         if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { | ||||
|                             // The server could do some transformations on user names, e.g. on some | ||||
|                             // wikis is uppercases the first letter. | ||||
|                             getExtendedInfo(loginResult.userName, loginResult, cb) | ||||
|                         } else if ("UI" == loginResult.status) { | ||||
|                             when (loginResult) { | ||||
|                                 is OAuthResult -> | ||||
|                                     cb.twoFactorPrompt( | ||||
|                                         LoginFailedException(loginResult.message), | ||||
|                                         loginToken, | ||||
|                                     ) | ||||
| 
 | ||||
|                             is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) | ||||
|                                 is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) | ||||
| 
 | ||||
|                             is LoginResult.Result -> cb.error( | ||||
|                                 LoginFailedException(loginResult.message) | ||||
|                             ) | ||||
|                                 is LoginResult.Result -> | ||||
|                                     cb.error( | ||||
|                                         LoginFailedException(loginResult.message), | ||||
|                                     ) | ||||
|                             } | ||||
|                         } else { | ||||
|                             cb.error(LoginFailedException(loginResult.message)) | ||||
|                         } | ||||
|                     } else { | ||||
|                         cb.error(LoginFailedException(loginResult.message)) | ||||
|                         cb.error(IOException("Login failed. Unexpected response.")) | ||||
|                     } | ||||
|                 } else { | ||||
|                     cb.error(IOException("Login failed. Unexpected response.")) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             override fun onFailure(call: Call<LoginResponse?>, t: Throwable) { | ||||
|                 if (call.isCanceled) { | ||||
|                     return | ||||
|                 override fun onFailure( | ||||
|                     call: Call<LoginResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     if (call.isCanceled) { | ||||
|                         return | ||||
|                     } | ||||
|                     cb.error(t) | ||||
|                 } | ||||
|                 cb.error(t) | ||||
|             } | ||||
|         }) | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun doLogin( | ||||
|  | @ -111,43 +149,65 @@ class LoginClient(private val loginInterface: LoginInterface) { | |||
|         password: String, | ||||
|         twoFactorCode: String, | ||||
|         userLanguage: String, | ||||
|         loginCallback: LoginCallback | ||||
|         loginCallback: LoginCallback, | ||||
|     ) { | ||||
|         getLoginToken().enqueue(object :Callback<MwQueryResponse?>{ | ||||
|             override fun onResponse( | ||||
|                 call: Call<MwQueryResponse?>, | ||||
|                 response: Response<MwQueryResponse?> | ||||
|             ) = if (response.isSuccessful){ | ||||
|                 val loginToken = response.body()?.query()?.loginToken() | ||||
|                 loginToken?.let { | ||||
|                     login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) | ||||
|                 } ?: run { | ||||
|         getLoginToken().enqueue( | ||||
|             object : Callback<MwQueryResponse?> { | ||||
|                 override fun onResponse( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     response: Response<MwQueryResponse?>, | ||||
|                 ) = if (response.isSuccessful) { | ||||
|                     val loginToken = response.body()?.query()?.loginToken() | ||||
|                     loginToken?.let { | ||||
|                         login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) | ||||
|                     } ?: run { | ||||
|                         loginCallback.error(IOException("Failed to retrieve login token")) | ||||
|                     } | ||||
|                 } else { | ||||
|                     loginCallback.error(IOException("Failed to retrieve login token")) | ||||
|                 } | ||||
|             } else { | ||||
|                 loginCallback.error(IOException("Failed to retrieve login token")) | ||||
|             } | ||||
| 
 | ||||
|             override fun onFailure(call: Call<MwQueryResponse?>, t: Throwable) { | ||||
|                 loginCallback.error(t) | ||||
|             } | ||||
|         }) | ||||
|                 override fun onFailure( | ||||
|                     call: Call<MwQueryResponse?>, | ||||
|                     t: Throwable, | ||||
|                 ) { | ||||
|                     loginCallback.error(t) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     @Throws(Throwable::class) | ||||
|     fun loginBlocking(userName: String, password: String, twoFactorCode: String?) { | ||||
|     fun loginBlocking( | ||||
|         userName: String, | ||||
|         password: String, | ||||
|         twoFactorCode: String?, | ||||
|     ) { | ||||
|         val tokenResponse = getLoginToken().execute() | ||||
|         if (tokenResponse.body()?.query()?.loginToken().isNullOrEmpty()) { | ||||
|         if (tokenResponse | ||||
|                 .body() | ||||
|                 ?.query() | ||||
|                 ?.loginToken() | ||||
|                 .isNullOrEmpty() | ||||
|         ) { | ||||
|             throw IOException("Unexpected response when getting login token.") | ||||
|         } | ||||
| 
 | ||||
|         val loginToken = tokenResponse.body()?.query()?.loginToken() | ||||
|         val tempLoginCall = if (twoFactorCode.isNullOrEmpty()) { | ||||
|             loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|         } else { | ||||
|             loginInterface.postLogIn( | ||||
|                 userName, password, null, twoFactorCode, loginToken, userLanguage, true | ||||
|             ) | ||||
|         } | ||||
|         val tempLoginCall = | ||||
|             if (twoFactorCode.isNullOrEmpty()) { | ||||
|                 loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) | ||||
|             } else { | ||||
|                 loginInterface.postLogIn( | ||||
|                     userName, | ||||
|                     password, | ||||
|                     null, | ||||
|                     twoFactorCode, | ||||
|                     loginToken, | ||||
|                     userLanguage, | ||||
|                     true, | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|         val response = tempLoginCall.execute() | ||||
|         val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.") | ||||
|  | @ -166,18 +226,23 @@ class LoginClient(private val loginInterface: LoginInterface) { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun getExtendedInfo(userName: String, loginResult: LoginResult, cb: LoginCallback) = | ||||
|         loginInterface.getUserInfo(userName) | ||||
|             .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) | ||||
|             .subscribe({ response: MwQueryResponse? -> | ||||
|                 loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 | ||||
|                 loginResult.groups = | ||||
|                     response?.query()?.getUserResponse(userName)?.groups ?: emptySet() | ||||
|                 cb.success(loginResult) | ||||
|             }, { caught: Throwable -> | ||||
|                 Timber.e(caught, "Login succeeded but getting group information failed. ") | ||||
|                 cb.error(caught) | ||||
|             }) | ||||
|     private fun getExtendedInfo( | ||||
|         userName: String, | ||||
|         loginResult: LoginResult, | ||||
|         cb: LoginCallback, | ||||
|     ) = loginInterface | ||||
|         .getUserInfo(userName) | ||||
|         .subscribeOn(Schedulers.io()) | ||||
|         .observeOn(AndroidSchedulers.mainThread()) | ||||
|         .subscribe({ response: MwQueryResponse? -> | ||||
|             loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 | ||||
|             loginResult.groups = | ||||
|                 response?.query()?.getUserResponse(userName)?.groups ?: emptySet() | ||||
|             cb.success(loginResult) | ||||
|         }, { caught: Throwable -> | ||||
|             Timber.e(caught, "Login succeeded but getting group information failed. ") | ||||
|             cb.error(caught) | ||||
|         }) | ||||
| 
 | ||||
|     fun cancel() { | ||||
|         tokenCall?.let { | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| class LoginFailedException(message: String?) : Throwable(message) | ||||
| class LoginFailedException( | ||||
|     message: String?, | ||||
| ) : Throwable(message) | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| package fr.free.nrw.commons.auth.login | ||||
| 
 | ||||
| import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX | ||||
| import io.reactivex.Observable | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.Observable | ||||
| import retrofit2.Call | ||||
| import retrofit2.http.Field | ||||
| import retrofit2.http.FormUrlEncoded | ||||
|  | @ -24,7 +24,7 @@ interface LoginInterface { | |||
|         @Field("password") pass: String?, | ||||
|         @Field("logintoken") token: String?, | ||||
|         @Field("uselang") userLanguage: String?, | ||||
|         @Field("loginreturnurl") url: String? | ||||
|         @Field("loginreturnurl") url: String?, | ||||
|     ): Call<LoginResponse?> | ||||
| 
 | ||||
|     @Headers("Cache-Control: no-cache") | ||||
|  | @ -37,9 +37,11 @@ interface LoginInterface { | |||
|         @Field("OATHToken") twoFactorCode: String?, | ||||
|         @Field("logintoken") token: String?, | ||||
|         @Field("uselang") userLanguage: String?, | ||||
|         @Field("logincontinue") loginContinue: Boolean | ||||
|         @Field("logincontinue") loginContinue: Boolean, | ||||
|     ): Call<LoginResponse?> | ||||
| 
 | ||||
|     @GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") | ||||
|     fun getUserInfo(@Query("ususers") userName: String): Observable<MwQueryResponse?> | ||||
| } | ||||
|     fun getUserInfo( | ||||
|         @Query("ususers") userName: String, | ||||
|     ): Observable<MwQueryResponse?> | ||||
| } | ||||
|  |  | |||
|  | @ -13,9 +13,7 @@ class LoginResponse { | |||
|     @SerializedName("clientlogin") | ||||
|     private val clientLogin: ClientLogin? = null | ||||
| 
 | ||||
|     fun toLoginResult(password: String): LoginResult? { | ||||
|         return clientLogin?.toLoginResult(password) | ||||
|     } | ||||
|     fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password) | ||||
| } | ||||
| 
 | ||||
| internal class ClientLogin { | ||||
|  | @ -39,7 +37,7 @@ internal class ClientLogin { | |||
|                 } | ||||
|             } | ||||
|         } else if ("PASS" != status && "FAIL" != status) { | ||||
|             //TODO: String resource -- Looks like needed for others in this class too | ||||
|             // TODO: String resource -- Looks like needed for others in this class too | ||||
|             userMessage = "An unknown error occurred." | ||||
|         } | ||||
|         return Result(status ?: "", userName, password, userMessage) | ||||
|  |  | |||
|  | @ -4,7 +4,7 @@ sealed class LoginResult( | |||
|     val status: String, | ||||
|     val userName: String?, | ||||
|     val password: String?, | ||||
|     val message: String? | ||||
|     val message: String?, | ||||
| ) { | ||||
|     var userId = 0 | ||||
|     var groups = emptySet<String>() | ||||
|  | @ -14,20 +14,20 @@ sealed class LoginResult( | |||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String? | ||||
|     ): LoginResult(status, userName, password, message) | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| 
 | ||||
|     class OAuthResult( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String? | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| 
 | ||||
|     class ResetPasswordResult( | ||||
|         status: String, | ||||
|         userName: String?, | ||||
|         password: String?, | ||||
|         message: String? | ||||
|         message: String?, | ||||
|     ) : LoginResult(status, userName, password, message) | ||||
| } | ||||
|  |  | |||
|  | @ -15,25 +15,34 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem | |||
| /** | ||||
|  * Helps to inflate Wikidata Items into Items tab | ||||
|  */ | ||||
| class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) : | ||||
|     RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() { | ||||
| 
 | ||||
|     class BookmarkItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { | ||||
| 
 | ||||
| class BookmarkItemsAdapter( | ||||
|     val list: List<DepictedItem>, | ||||
|     val context: Context, | ||||
| ) : RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() { | ||||
|     class BookmarkItemViewHolder( | ||||
|         itemView: View, | ||||
|     ) : RecyclerView.ViewHolder(itemView) { | ||||
|         var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) | ||||
|         var description: TextView = itemView.findViewById(R.id.description) | ||||
|         var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) | ||||
|         var layout : ConstraintLayout = itemView.findViewById(R.id.layout_item) | ||||
|         var layout: ConstraintLayout = itemView.findViewById(R.id.layout_item) | ||||
|     } | ||||
| 
 | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookmarkItemViewHolder { | ||||
|         val v: View = LayoutInflater.from(context) | ||||
|             .inflate(R.layout.item_depictions, parent, false) | ||||
|     override fun onCreateViewHolder( | ||||
|         parent: ViewGroup, | ||||
|         viewType: Int, | ||||
|     ): BookmarkItemViewHolder { | ||||
|         val v: View = | ||||
|             LayoutInflater | ||||
|                 .from(context) | ||||
|                 .inflate(R.layout.item_depictions, parent, false) | ||||
|         return BookmarkItemViewHolder(v) | ||||
|     } | ||||
| 
 | ||||
|     override fun onBindViewHolder(holder: BookmarkItemViewHolder, position: Int) { | ||||
| 
 | ||||
|     override fun onBindViewHolder( | ||||
|         holder: BookmarkItemViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         val depictedItem = list[position] | ||||
|         holder.depictsLabel.text = depictedItem.name | ||||
|         holder.description.text = depictedItem.description | ||||
|  | @ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun getItemCount(): Int { | ||||
|         return list.size | ||||
|     } | ||||
| } | ||||
|     override fun getItemCount(): Int = list.size | ||||
| } | ||||
|  |  | |||
|  | @ -2,12 +2,15 @@ package fr.free.nrw.commons.bookmarks.models | |||
| 
 | ||||
| import android.net.Uri | ||||
| 
 | ||||
| class Bookmark(mediaName: String?, mediaCreator: String?, | ||||
|                /** | ||||
|                 * Modifies the content URI - marking this bookmark as already saved in the database | ||||
|                 * @param contentUri the content URI | ||||
|                 */ | ||||
|                var contentUri: Uri?) { | ||||
| class Bookmark( | ||||
|     mediaName: String?, | ||||
|     mediaCreator: String?, | ||||
|     /** | ||||
|      * Modifies the content URI - marking this bookmark as already saved in the database | ||||
|      * @param contentUri the content URI | ||||
|      */ | ||||
|     var contentUri: Uri?, | ||||
| ) { | ||||
|     /** | ||||
|      * Gets the content URI for this bookmark | ||||
|      * @return content URI | ||||
|  | @ -17,10 +20,10 @@ class Bookmark(mediaName: String?, mediaCreator: String?, | |||
|      * @return the media name | ||||
|      */ | ||||
|     val mediaName: String = mediaName ?: "" | ||||
| 
 | ||||
|     /** | ||||
|      * Gets media creator | ||||
|      * @return creator name | ||||
|      */ | ||||
|     val mediaCreator: String = mediaCreator ?: "" | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName | |||
| class CampaignConfig { | ||||
|     @SerializedName("showOnlyLiveCampaigns") | ||||
|     private val showOnlyLiveCampaigns = false | ||||
| 
 | ||||
|     @SerializedName("sortBy") | ||||
|     private val sortBy: String? = null | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ import fr.free.nrw.commons.campaigns.models.Campaign | |||
| class CampaignResponseDTO { | ||||
|     @SerializedName("config") | ||||
|     val campaignConfig: CampaignConfig? = null | ||||
| 
 | ||||
|     @SerializedName("campaigns") | ||||
|     val campaigns: List<Campaign>? = null | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -3,9 +3,11 @@ package fr.free.nrw.commons.campaigns.models | |||
| /** | ||||
|  * A data class to hold a campaign | ||||
|  */ | ||||
| data class Campaign(var title: String? = null, | ||||
|                     var description: String? = null, | ||||
|                     var startDate: String? = null, | ||||
|                     var endDate: String? = null, | ||||
|                     var link: String? = null, | ||||
|                     var isWLMCampaign: Boolean = false) | ||||
| data class Campaign( | ||||
|     var title: String? = null, | ||||
|     var description: String? = null, | ||||
|     var startDate: String? = null, | ||||
|     var endDate: String? = null, | ||||
|     var link: String? = null, | ||||
|     var isWLMCampaign: Boolean = false, | ||||
| ) | ||||
|  |  | |||
|  | @ -14,269 +14,280 @@ import javax.inject.Inject | |||
| /** | ||||
|  * The model class for categories in upload | ||||
|  */ | ||||
| class CategoriesModel @Inject constructor( | ||||
|     private val categoryClient: CategoryClient, | ||||
|     private val categoryDao: CategoryDao, | ||||
|     private val gpsCategoryModel: GpsCategoryModel | ||||
| ) { | ||||
|     private val selectedCategories: MutableList<CategoryItem> = mutableListOf() | ||||
| class CategoriesModel | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val categoryClient: CategoryClient, | ||||
|         private val categoryDao: CategoryDao, | ||||
|         private val gpsCategoryModel: GpsCategoryModel, | ||||
|     ) { | ||||
|         private val selectedCategories: MutableList<CategoryItem> = mutableListOf() | ||||
| 
 | ||||
|     /** | ||||
|      * Existing categories which are selected | ||||
|      */ | ||||
|     private var selectedExistingCategories: MutableList<String> = mutableListOf() | ||||
|         /** | ||||
|          * Existing categories which are selected | ||||
|          */ | ||||
|         private var selectedExistingCategories: MutableList<String> = mutableListOf() | ||||
| 
 | ||||
|     /** | ||||
|      * Returns true if an item is considered to be a spammy category which should be ignored | ||||
|      * | ||||
|      * @param item a category item that needs to be validated to know if it is spammy or not | ||||
|      * @return | ||||
|      */ | ||||
|     fun isSpammyCategory(item: String): Boolean { | ||||
|         //Check for current and previous year to exclude these categories from removal | ||||
|         val now = Calendar.getInstance() | ||||
|         val curYear = now[Calendar.YEAR] | ||||
|         val curYearInString = curYear.toString() | ||||
|         val prevYear = curYear - 1 | ||||
|         val prevYearInString = prevYear.toString() | ||||
|         Timber.d("Previous year: %s", prevYearInString) | ||||
|         /** | ||||
|          * Returns true if an item is considered to be a spammy category which should be ignored | ||||
|          * | ||||
|          * @param item a category item that needs to be validated to know if it is spammy or not | ||||
|          * @return | ||||
|          */ | ||||
|         fun isSpammyCategory(item: String): Boolean { | ||||
|             // Check for current and previous year to exclude these categories from removal | ||||
|             val now = Calendar.getInstance() | ||||
|             val curYear = now[Calendar.YEAR] | ||||
|             val curYearInString = curYear.toString() | ||||
|             val prevYear = curYear - 1 | ||||
|             val prevYearInString = prevYear.toString() | ||||
|             Timber.d("Previous year: %s", prevYearInString) | ||||
| 
 | ||||
|         val mentionsDecade = item.matches(".*0s.*".toRegex()) | ||||
|         val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) | ||||
|         val spammyCategory = item.matches("(.*)needing(.*)".toRegex()) | ||||
|                           || item.matches("(.*)taken on(.*)".toRegex()) | ||||
|             val mentionsDecade = item.matches(".*0s.*".toRegex()) | ||||
|             val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) | ||||
|             val spammyCategory = | ||||
|                 item.matches("(.*)needing(.*)".toRegex()) || | ||||
|                     item.matches("(.*)taken on(.*)".toRegex()) | ||||
| 
 | ||||
|         // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) | ||||
|         if (spammyCategory) { | ||||
|             return true | ||||
|             // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750) | ||||
|             if (spammyCategory) { | ||||
|                 return true | ||||
|             } | ||||
| 
 | ||||
|             if (mentionsDecade) { | ||||
|                 // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 | ||||
|                 // Example: "2020s" is OK, but "1920s" is not (and should be skipped) | ||||
|                 return !recentDecade | ||||
|             } else { | ||||
|                 // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year | ||||
|                 // anywhere within the string (.* is wildcard) (Issue #47) | ||||
|                 // And that item does not equal the current year or previous year | ||||
|                 return item.matches(".*(19|20)\\d{2}.*".toRegex()) && | ||||
|                     !item.contains(curYearInString) && | ||||
|                     !item.contains(prevYearInString) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (mentionsDecade) { | ||||
|             // Check if the year in the form of XX(X)0s is recent/relevant, i.e. in the 2000s or 2010s/2020s as stated in Issue #1029 | ||||
|             // Example: "2020s" is OK, but "1920s" is not (and should be skipped) | ||||
|             return !recentDecade | ||||
|         } else { | ||||
|             // If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year | ||||
|             // anywhere within the string (.* is wildcard) (Issue #47) | ||||
|             // And that item does not equal the current year or previous year | ||||
|             return item.matches(".*(19|20)\\d{2}.*".toRegex()) | ||||
|                     && !item.contains(curYearInString) | ||||
|                     && !item.contains(prevYearInString) | ||||
|         /** | ||||
|          * Updates category count in category dao | ||||
|          * @param item | ||||
|          */ | ||||
|         fun updateCategoryCount(item: CategoryItem) { | ||||
|             var category = categoryDao.find(item.name) | ||||
| 
 | ||||
|             // Newly used category... | ||||
|             if (category == null) { | ||||
|                 category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) | ||||
|             } | ||||
|             category.incTimesUsed() | ||||
|             categoryDao.save(category) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Updates category count in category dao | ||||
|      * @param item | ||||
|      */ | ||||
|     fun updateCategoryCount(item: CategoryItem) { | ||||
|         var category = categoryDao.find(item.name) | ||||
|         /** | ||||
|          * Regional category search | ||||
|          * @param term | ||||
|          * @param imageTitleList | ||||
|          * @return | ||||
|          */ | ||||
|         fun searchAll( | ||||
|             term: String, | ||||
|             imageTitleList: List<String>, | ||||
|             selectedDepictions: List<DepictedItem>, | ||||
|         ): Observable<List<CategoryItem>> = | ||||
|             suggestionsOrSearch(term, imageTitleList, selectedDepictions) | ||||
|                 .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } | ||||
| 
 | ||||
|         // Newly used category... | ||||
|         if (category == null) { | ||||
|             category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) | ||||
|         } | ||||
|         category.incTimesUsed() | ||||
|         categoryDao.save(category) | ||||
|     } | ||||
|         private fun suggestionsOrSearch( | ||||
|             term: String, | ||||
|             imageTitleList: List<String>, | ||||
|             selectedDepictions: List<DepictedItem>, | ||||
|         ): Observable<List<CategoryItem>> = | ||||
|             if (TextUtils.isEmpty(term)) { | ||||
|                 Observable.combineLatest( | ||||
|                     categoriesFromDepiction(selectedDepictions), | ||||
|                     gpsCategoryModel.categoriesFromLocation, | ||||
|                     titleCategories(imageTitleList), | ||||
|                     Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), | ||||
|                     Function4(::combine), | ||||
|                 ) | ||||
|             } else { | ||||
|                 categoryClient | ||||
|                     .searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) | ||||
|                     .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } | ||||
|                     .toObservable() | ||||
|             } | ||||
| 
 | ||||
|     /** | ||||
|      * Regional category search | ||||
|      * @param term | ||||
|      * @param imageTitleList | ||||
|      * @return | ||||
|      */ | ||||
|     fun searchAll( | ||||
|         term: String, | ||||
|         imageTitleList: List<String>, | ||||
|         selectedDepictions: List<DepictedItem> | ||||
|     ): Observable<List<CategoryItem>> { | ||||
|         return suggestionsOrSearch(term, imageTitleList, selectedDepictions) | ||||
|             .map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } | ||||
|     } | ||||
| 
 | ||||
|     private fun suggestionsOrSearch( | ||||
|         term: String, | ||||
|         imageTitleList: List<String>, | ||||
|         selectedDepictions: List<DepictedItem> | ||||
|     ): Observable<List<CategoryItem>> { | ||||
|         return if (TextUtils.isEmpty(term)) | ||||
|             Observable.combineLatest( | ||||
|                 categoriesFromDepiction(selectedDepictions), | ||||
|                 gpsCategoryModel.categoriesFromLocation, | ||||
|                 titleCategories(imageTitleList), | ||||
|                 Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), | ||||
|                 Function4(::combine) | ||||
|             ) | ||||
|         else | ||||
|             categoryClient.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT) | ||||
|                 .map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) } | ||||
|         /** | ||||
|          * Fetches details of every category associated with selected depictions, converts them into | ||||
|          * CategoryItem and returns them in a list. | ||||
|          * | ||||
|          * @param selectedDepictions selected DepictItems | ||||
|          * @return List of CategoryItem associated with selected depictions | ||||
|          */ | ||||
|         private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? = | ||||
|             Observable | ||||
|                 .fromIterable( | ||||
|                     selectedDepictions.map { it.commonsCategories }.flatten(), | ||||
|                 ).map { categoryItem -> | ||||
|                     categoryClient | ||||
|                         .getCategoriesByName( | ||||
|                             categoryItem.name, | ||||
|                             categoryItem.name, | ||||
|                             SEARCH_CATS_LIMIT, | ||||
|                         ).map { | ||||
|                             CategoryItem( | ||||
|                                 it[0].name, | ||||
|                                 it[0].description, | ||||
|                                 it[0].thumbnail, | ||||
|                                 it[0].isSelected, | ||||
|                             ) | ||||
|                         }.blockingGet() | ||||
|                 }.toList() | ||||
|                 .toObservable() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches details of every category associated with selected depictions, converts them into | ||||
|      * CategoryItem and returns them in a list. | ||||
|      * | ||||
|      * @param selectedDepictions selected DepictItems | ||||
|      * @return List of CategoryItem associated with selected depictions | ||||
|      */ | ||||
|     private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): | ||||
|             Observable<MutableList<CategoryItem>>? { | ||||
|         return Observable.fromIterable( | ||||
|                 selectedDepictions.map { it.commonsCategories }.flatten()) | ||||
|                 .map { categoryItem -> | ||||
|                     categoryClient.getCategoriesByName(categoryItem.name, | ||||
|                         categoryItem.name, SEARCH_CATS_LIMIT).map { | ||||
|         /** | ||||
|          * Fetches details of every category by their name, converts them into | ||||
|          * CategoryItem and returns them in a list. | ||||
|          * | ||||
|          * @param categoryNames selected Categories | ||||
|          * @return List of CategoryItem | ||||
|          */ | ||||
|         fun getCategoriesByName(categoryNames: List<String>): Observable<MutableList<CategoryItem>>? = | ||||
|             Observable | ||||
|                 .fromIterable(categoryNames) | ||||
|                 .map { categoryName -> | ||||
|                     buildCategories(categoryName) | ||||
|                 }.filter { categoryItem -> | ||||
|                     categoryItem.name != "Hidden" | ||||
|                 }.toList() | ||||
|                 .toObservable() | ||||
| 
 | ||||
|                         CategoryItem(it[0].name, it[0].description, | ||||
|                             it[0].thumbnail, it[0].isSelected) | ||||
|         /** | ||||
|          * Fetches the categories and converts them into CategoryItem | ||||
|          */ | ||||
|         fun buildCategories(categoryName: String): CategoryItem = | ||||
|             categoryClient | ||||
|                 .getCategoriesByName( | ||||
|                     categoryName, | ||||
|                     categoryName, | ||||
|                     SEARCH_CATS_LIMIT, | ||||
|                 ).map { | ||||
|                     if (it.isNotEmpty()) { | ||||
|                         CategoryItem( | ||||
|                             it[0].name, | ||||
|                             it[0].description, | ||||
|                             it[0].thumbnail, | ||||
|                             it[0].isSelected, | ||||
|                         ) | ||||
|                     } else { | ||||
|                         CategoryItem( | ||||
|                             "Hidden", | ||||
|                             "Hidden", | ||||
|                             "hidden", | ||||
|                             false, | ||||
|                         ) | ||||
|                     } | ||||
|                 }.blockingGet() | ||||
| 
 | ||||
|                     }.blockingGet() | ||||
|                 }.toList().toObservable() | ||||
|     } | ||||
|         private fun combine( | ||||
|             depictionCategories: List<CategoryItem>, | ||||
|             locationCategories: List<CategoryItem>, | ||||
|             titles: List<CategoryItem>, | ||||
|             recents: List<CategoryItem>, | ||||
|         ) = depictionCategories + locationCategories + titles + recents | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches details of every category by their name, converts them into | ||||
|      * CategoryItem and returns them in a list. | ||||
|      * | ||||
|      * @param categoryNames selected Categories | ||||
|      * @return List of CategoryItem | ||||
|      */ | ||||
|      fun getCategoriesByName(categoryNames: List<String>): | ||||
|             Observable<MutableList<CategoryItem>>? { | ||||
|         return Observable.fromIterable(categoryNames) | ||||
|             .map { categoryName -> | ||||
|                 buildCategories(categoryName) | ||||
|             } | ||||
|             .filter { categoryItem -> | ||||
|                 categoryItem.name != "Hidden" | ||||
|             } | ||||
|             .toList().toObservable() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches the categories and converts them into CategoryItem | ||||
|      */ | ||||
|     fun buildCategories(categoryName: String): CategoryItem { | ||||
|         return categoryClient.getCategoriesByName(categoryName, | ||||
|             categoryName, SEARCH_CATS_LIMIT).map { | ||||
|             if(it.isNotEmpty()) { | ||||
|                 CategoryItem( | ||||
|                     it[0].name, it[0].description, | ||||
|                     it[0].thumbnail, it[0].isSelected | ||||
|                 ) | ||||
|             } else { | ||||
|                 CategoryItem( | ||||
|                     "Hidden", "Hidden", | ||||
|                     "hidden", false | ||||
|                 ) | ||||
|             } | ||||
|         }.blockingGet() | ||||
|     } | ||||
| 
 | ||||
|     private fun combine( | ||||
|         depictionCategories: List<CategoryItem>, | ||||
|         locationCategories: List<CategoryItem>, | ||||
|         titles: List<CategoryItem>, | ||||
|         recents: List<CategoryItem> | ||||
|     ) = depictionCategories + locationCategories + titles + recents | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Returns title based categories | ||||
|      * @param titleList | ||||
|      * @return | ||||
|      */ | ||||
|     private fun titleCategories(titleList: List<String>) = | ||||
|         if (titleList.isNotEmpty()) | ||||
|             Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> | ||||
|                 searchResults.map { it as List<CategoryItem> }.flatten() | ||||
|             } | ||||
|         else | ||||
|             Observable.just(emptyList()) | ||||
| 
 | ||||
|     /** | ||||
|      * Return category for single title | ||||
|      * @param title | ||||
|      * @return | ||||
|      */ | ||||
|     private fun getTitleCategories(title: String): Observable<List<CategoryItem>> { | ||||
|         return categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Handles category item selection | ||||
|      * @param item | ||||
|      */ | ||||
|     fun onCategoryItemClicked(item: CategoryItem, media: Media?) { | ||||
|         if (media == null) { | ||||
|             if (item.isSelected) { | ||||
|                 selectedCategories.add(item) | ||||
|                 updateCategoryCount(item) | ||||
|             } else { | ||||
|                 selectedCategories.remove(item) | ||||
|             } | ||||
|         } else { | ||||
|             if (item.isSelected) { | ||||
|                 if (media.categories?.contains(item.name) == true) { | ||||
|                     selectedExistingCategories.add(item.name) | ||||
|                 } else { | ||||
|                     selectedCategories.add(item) | ||||
|                     updateCategoryCount(item) | ||||
|         /** | ||||
|          * Returns title based categories | ||||
|          * @param titleList | ||||
|          * @return | ||||
|          */ | ||||
|         private fun titleCategories(titleList: List<String>) = | ||||
|             if (titleList.isNotEmpty()) { | ||||
|                 Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults -> | ||||
|                     searchResults.map { it as List<CategoryItem> }.flatten() | ||||
|                 } | ||||
|             } else { | ||||
|                 if (media.categories?.contains(item.name) == true) { | ||||
|                     selectedExistingCategories.remove(item.name) | ||||
|                     if (!media.categories?.contains(item.name)!!) { | ||||
|                         val categoriesList: MutableList<String> = ArrayList() | ||||
|                         categoriesList.add(item.name) | ||||
|                         categoriesList.addAll(media.categories!!) | ||||
|                         media.categories = categoriesList | ||||
|                     } | ||||
|                 Observable.just(emptyList()) | ||||
|             } | ||||
| 
 | ||||
|         /** | ||||
|          * Return category for single title | ||||
|          * @param title | ||||
|          * @return | ||||
|          */ | ||||
|         private fun getTitleCategories(title: String): Observable<List<CategoryItem>> = | ||||
|             categoryClient.searchCategories(title, SEARCH_CATS_LIMIT).toObservable() | ||||
| 
 | ||||
|         /** | ||||
|          * Handles category item selection | ||||
|          * @param item | ||||
|          */ | ||||
|         fun onCategoryItemClicked( | ||||
|             item: CategoryItem, | ||||
|             media: Media?, | ||||
|         ) { | ||||
|             if (media == null) { | ||||
|                 if (item.isSelected) { | ||||
|                     selectedCategories.add(item) | ||||
|                     updateCategoryCount(item) | ||||
|                 } else { | ||||
|                     selectedCategories.remove(item) | ||||
|                 } | ||||
|             } else { | ||||
|                 if (item.isSelected) { | ||||
|                     if (media.categories?.contains(item.name) == true) { | ||||
|                         selectedExistingCategories.add(item.name) | ||||
|                     } else { | ||||
|                         selectedCategories.add(item) | ||||
|                         updateCategoryCount(item) | ||||
|                     } | ||||
|                 } else { | ||||
|                     if (media.categories?.contains(item.name) == true) { | ||||
|                         selectedExistingCategories.remove(item.name) | ||||
|                         if (!media.categories?.contains(item.name)!!) { | ||||
|                             val categoriesList: MutableList<String> = ArrayList() | ||||
|                             categoriesList.add(item.name) | ||||
|                             categoriesList.addAll(media.categories!!) | ||||
|                             media.categories = categoriesList | ||||
|                         } | ||||
|                     } else { | ||||
|                         selectedCategories.remove(item) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get Selected Categories | ||||
|      * @return | ||||
|      */ | ||||
|     fun getSelectedCategories(): List<CategoryItem> { | ||||
|         return selectedCategories | ||||
|     } | ||||
|         /** | ||||
|          * Get Selected Categories | ||||
|          * @return | ||||
|          */ | ||||
|         fun getSelectedCategories(): List<CategoryItem> = selectedCategories | ||||
| 
 | ||||
|     /** | ||||
|      * Cleanup the existing in memory cache's | ||||
|      */ | ||||
|     fun cleanUp() { | ||||
|         selectedCategories.clear() | ||||
|         selectedExistingCategories.clear() | ||||
|     } | ||||
|         /** | ||||
|          * Cleanup the existing in memory cache's | ||||
|          */ | ||||
|         fun cleanUp() { | ||||
|             selectedCategories.clear() | ||||
|             selectedExistingCategories.clear() | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
|         const val SEARCH_CATS_LIMIT = 25 | ||||
|     } | ||||
|         companion object { | ||||
|             const val SEARCH_CATS_LIMIT = 25 | ||||
|         } | ||||
| 
 | ||||
|     /** | ||||
|      * Provides selected existing categories | ||||
|      * | ||||
|      * @return selected existing categories | ||||
|      */ | ||||
|     fun getSelectedExistingCategories(): List<String> { | ||||
|         return selectedExistingCategories | ||||
|     } | ||||
|         /** | ||||
|          * Provides selected existing categories | ||||
|          * | ||||
|          * @return selected existing categories | ||||
|          */ | ||||
|         fun getSelectedExistingCategories(): List<String> = selectedExistingCategories | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize existing categories | ||||
|      * | ||||
|      * @param selectedExistingCategories existing categories | ||||
|      */ | ||||
|     fun setSelectedExistingCategories(selectedExistingCategories: MutableList<String>) { | ||||
|         this.selectedExistingCategories = selectedExistingCategories | ||||
|         /** | ||||
|          * Initialize existing categories | ||||
|          * | ||||
|          * @param selectedExistingCategories existing categories | ||||
|          */ | ||||
|         fun setSelectedExistingCategories(selectedExistingCategories: MutableList<String>) { | ||||
|             this.selectedExistingCategories = selectedExistingCategories | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| package fr.free.nrw.commons.category | ||||
| 
 | ||||
| import io.reactivex.Single | ||||
| import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse | ||||
| import io.reactivex.Single | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
|  | @ -15,109 +15,123 @@ const val CATEGORY_NEEDING_CATEGORIES = "needing categories" | |||
|  * Category Client to handle custom calls to Commons MediaWiki APIs | ||||
|  */ | ||||
| @Singleton | ||||
| class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) : | ||||
|     ContinuationClient<MwQueryResponse, CategoryItem>() { | ||||
| class CategoryClient | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val categoryInterface: CategoryInterface, | ||||
|     ) : ContinuationClient<MwQueryResponse, CategoryItem>() { | ||||
|         /** | ||||
|          * Searches for categories containing the specified string. | ||||
|          * | ||||
|          * @param filter    The string to be searched | ||||
|          * @param itemLimit How many results are returned | ||||
|          * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result | ||||
|          * @return | ||||
|          */ | ||||
|         @JvmOverloads | ||||
|         fun searchCategories( | ||||
|             filter: String?, | ||||
|             itemLimit: Int, | ||||
|             offset: Int = 0, | ||||
|         ): Single<List<CategoryItem>> = responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) | ||||
| 
 | ||||
|     /** | ||||
|      * Searches for categories containing the specified string. | ||||
|      * | ||||
|      * @param filter    The string to be searched | ||||
|      * @param itemLimit How many results are returned | ||||
|      * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result | ||||
|      * @return | ||||
|      */ | ||||
|     @JvmOverloads | ||||
|     fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0): | ||||
|             Single<List<CategoryItem>> { | ||||
|         return responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Searches for categories starting with the specified string. | ||||
|      * | ||||
|      * @param prefix    The prefix to be searched | ||||
|      * @param itemLimit How many results are returned | ||||
|      * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result | ||||
|      * @return | ||||
|      */ | ||||
|     @JvmOverloads | ||||
|     fun searchCategoriesForPrefix(prefix: String?, itemLimit: Int, offset: Int = 0): | ||||
|             Single<List<CategoryItem>> { | ||||
|         return responseMapper( | ||||
|             categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches categories starting and ending with a specified name. | ||||
|      * | ||||
|      * @param startingCategoryName Name of the category to start | ||||
|      * @param endingCategoryName Name of the category to end | ||||
|      * @param itemLimit How many categories to return | ||||
|      * @param offset offset | ||||
|      * @return MwQueryResponse | ||||
|      */ | ||||
|     @JvmOverloads | ||||
|     fun getCategoriesByName(startingCategoryName: String?, endingCategoryName: String?, | ||||
|                             itemLimit: Int, offset: Int = 0): Single<List<CategoryItem>> { | ||||
|         return responseMapper( | ||||
|             categoryInterface.getCategoriesByName(startingCategoryName, endingCategoryName, | ||||
|                 itemLimit, offset) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The method takes categoryName as input and returns a List of Subcategories | ||||
|      * It uses the generator query API to get the subcategories in a category, 500 at a time. | ||||
|      * | ||||
|      * @param categoryName Category name as defined on commons | ||||
|      * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. | ||||
|      */ | ||||
|     fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> { | ||||
|         return continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||
|             categoryInterface.getSubCategoryList( | ||||
|                 categoryName, it | ||||
|         /** | ||||
|          * Searches for categories starting with the specified string. | ||||
|          * | ||||
|          * @param prefix    The prefix to be searched | ||||
|          * @param itemLimit How many results are returned | ||||
|          * @param offset    Starts returning items from the nth result. If offset is 9, the response starts with the 9th item of the search result | ||||
|          * @return | ||||
|          */ | ||||
|         @JvmOverloads | ||||
|         fun searchCategoriesForPrefix( | ||||
|             prefix: String?, | ||||
|             itemLimit: Int, | ||||
|             offset: Int = 0, | ||||
|         ): Single<List<CategoryItem>> = | ||||
|             responseMapper( | ||||
|                 categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The method takes categoryName as input and returns a List of parent categories | ||||
|      * It uses the generator query API to get the parent categories of a category, 500 at a time. | ||||
|      * | ||||
|      * @param categoryName Category name as defined on commons | ||||
|      * @return | ||||
|      */ | ||||
|     fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> { | ||||
|         return continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||
|             categoryInterface.getParentCategoryList(categoryName, it) | ||||
|         } | ||||
|     } | ||||
|         /** | ||||
|          * Fetches categories starting and ending with a specified name. | ||||
|          * | ||||
|          * @param startingCategoryName Name of the category to start | ||||
|          * @param endingCategoryName Name of the category to end | ||||
|          * @param itemLimit How many categories to return | ||||
|          * @param offset offset | ||||
|          * @return MwQueryResponse | ||||
|          */ | ||||
|         @JvmOverloads | ||||
|         fun getCategoriesByName( | ||||
|             startingCategoryName: String?, | ||||
|             endingCategoryName: String?, | ||||
|             itemLimit: Int, | ||||
|             offset: Int = 0, | ||||
|         ): Single<List<CategoryItem>> = | ||||
|             responseMapper( | ||||
|                 categoryInterface.getCategoriesByName( | ||||
|                     startingCategoryName, | ||||
|                     endingCategoryName, | ||||
|                     itemLimit, | ||||
|                     offset, | ||||
|                 ), | ||||
|             ) | ||||
| 
 | ||||
|     fun resetSubCategoryContinuation(category: String) { | ||||
|         resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) | ||||
|     } | ||||
| 
 | ||||
|     fun resetParentCategoryContinuation(category: String) { | ||||
|         resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) | ||||
|     } | ||||
| 
 | ||||
|     override fun responseMapper( | ||||
|         networkResult: Single<MwQueryResponse>, | ||||
|         key: String? | ||||
|     ): Single<List<CategoryItem>> { | ||||
|         return networkResult | ||||
|             .map { | ||||
|                 handleContinuationResponse(it.continuation(), key) | ||||
|                 it.query()?.pages() ?: emptyList() | ||||
|         /** | ||||
|          * The method takes categoryName as input and returns a List of Subcategories | ||||
|          * It uses the generator query API to get the subcategories in a category, 500 at a time. | ||||
|          * | ||||
|          * @param categoryName Category name as defined on commons | ||||
|          * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted. | ||||
|          */ | ||||
|         fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> = | ||||
|             continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||
|                 categoryInterface.getSubCategoryList( | ||||
|                     categoryName, | ||||
|                     it, | ||||
|                 ) | ||||
|             } | ||||
|             .map { | ||||
|                 it.filter { | ||||
|                     page -> page.categoryInfo() == null || !page.categoryInfo().isHidden | ||||
| 
 | ||||
|         /** | ||||
|          * The method takes categoryName as input and returns a List of parent categories | ||||
|          * It uses the generator query API to get the parent categories of a category, 500 at a time. | ||||
|          * | ||||
|          * @param categoryName Category name as defined on commons | ||||
|          * @return | ||||
|          */ | ||||
|         fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> = | ||||
|             continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { | ||||
|                 categoryInterface.getParentCategoryList(categoryName, it) | ||||
|             } | ||||
| 
 | ||||
|         fun resetSubCategoryContinuation(category: String) { | ||||
|             resetContinuation(SUB_CATEGORY_CONTINUATION_PREFIX, category) | ||||
|         } | ||||
| 
 | ||||
|         fun resetParentCategoryContinuation(category: String) { | ||||
|             resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) | ||||
|         } | ||||
| 
 | ||||
|         override fun responseMapper( | ||||
|             networkResult: Single<MwQueryResponse>, | ||||
|             key: String?, | ||||
|         ): Single<List<CategoryItem>> = | ||||
|             networkResult | ||||
|                 .map { | ||||
|                     handleContinuationResponse(it.continuation(), key) | ||||
|                     it.query()?.pages() ?: emptyList() | ||||
|                 }.map { | ||||
|                     CategoryItem(it.title().replace(CATEGORY_PREFIX, ""), | ||||
|                         it.description().toString(), it.thumbUrl().toString(), false) | ||||
|                     it | ||||
|                         .filter { page -> | ||||
|                             page.categoryInfo() == null || !page.categoryInfo().isHidden | ||||
|                         }.map { | ||||
|                             CategoryItem( | ||||
|                                 it.title().replace(CATEGORY_PREFIX, ""), | ||||
|                                 it.description().toString(), | ||||
|                                 it.thumbUrl().toString(), | ||||
|                                 false, | ||||
|                             ) | ||||
|                         } | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -17,11 +17,13 @@ interface CategoryInterface { | |||
|      * @param itemLimit How many results are returned | ||||
|      * @return | ||||
|      */ | ||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14") | ||||
|     @GET( | ||||
|         "w/api.php?action=query&format=json&formatversion=2&generator=search&prop=description|pageimages&piprop=thumbnail&pithumbsize=70&gsrnamespace=14", | ||||
|     ) | ||||
|     fun searchCategories( | ||||
|         @Query("gsrsearch") filter: String?, | ||||
|         @Query("gsrlimit") itemLimit: Int, | ||||
|         @Query("gsroffset") offset: Int | ||||
|         @Query("gsroffset") offset: Int, | ||||
|     ): Single<MwQueryResponse> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -31,11 +33,13 @@ interface CategoryInterface { | |||
|      * @param itemLimit How many results are returned | ||||
|      * @return | ||||
|      */ | ||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70") | ||||
|     @GET( | ||||
|         "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", | ||||
|     ) | ||||
|     fun searchCategoriesForPrefix( | ||||
|         @Query("gacprefix") prefix: String?, | ||||
|         @Query("gaclimit") itemLimit: Int, | ||||
|         @Query("gacoffset") offset: Int | ||||
|         @Query("gacoffset") offset: Int, | ||||
|     ): Single<MwQueryResponse> | ||||
| 
 | ||||
|     /** | ||||
|  | @ -47,23 +51,25 @@ interface CategoryInterface { | |||
|      * @param offset offset | ||||
|      * @return MwQueryResponse | ||||
|      */ | ||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70") | ||||
|     @GET( | ||||
|         "w/api.php?action=query&format=json&formatversion=2&generator=allcategories&prop=categoryinfo|description|pageimages&piprop=thumbnail&pithumbsize=70", | ||||
|     ) | ||||
|     fun getCategoriesByName( | ||||
|         @Query("gacfrom") startingCategory: String?, | ||||
|         @Query("gacto") endingCategory: String?, | ||||
|         @Query("gaclimit") itemLimit: Int, | ||||
|         @Query("gacoffset") offset: Int | ||||
|         @Query("gacoffset") offset: Int, | ||||
|     ): Single<MwQueryResponse> | ||||
| 
 | ||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") | ||||
|     fun getSubCategoryList( | ||||
|         @Query("gcmtitle") categoryName: String, | ||||
|         @QueryMap(encoded = true) continuation: Map<String, String> | ||||
|         @QueryMap(encoded = true) continuation: Map<String, String>, | ||||
|     ): Single<MwQueryResponse> | ||||
| 
 | ||||
|     @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") | ||||
|     fun getParentCategoryList( | ||||
|         @Query("titles") categoryName: String?, | ||||
|         @QueryMap(encoded = true) continuation: Map<String, String> | ||||
|         @QueryMap(encoded = true) continuation: Map<String, String>, | ||||
|     ): Single<MwQueryResponse> | ||||
| } | ||||
|  |  | |||
|  | @ -4,12 +4,13 @@ import android.os.Parcelable | |||
| import kotlinx.parcelize.Parcelize | ||||
| 
 | ||||
| @Parcelize | ||||
| data class CategoryItem(val name: String, val description: String?, | ||||
|                         val thumbnail: String?, var isSelected: Boolean) : Parcelable { | ||||
| 
 | ||||
|     override fun toString(): String { | ||||
|         return "CategoryItem: '$name'" | ||||
|     } | ||||
| data class CategoryItem( | ||||
|     val name: String, | ||||
|     val description: String?, | ||||
|     val thumbnail: String?, | ||||
|     var isSelected: Boolean, | ||||
| ) : Parcelable { | ||||
|     override fun toString(): String = "CategoryItem: '$name'" | ||||
| 
 | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|  | @ -22,7 +23,5 @@ data class CategoryItem(val name: String, val description: String?, | |||
|         return true | ||||
|     } | ||||
| 
 | ||||
|     override fun hashCode(): Int { | ||||
|         return name.hashCode() | ||||
|     } | ||||
|     override fun hashCode(): Int = name.hashCode() | ||||
| } | ||||
|  |  | |||
|  | @ -2,16 +2,16 @@ package fr.free.nrw.commons.category | |||
| 
 | ||||
| import io.reactivex.Single | ||||
| 
 | ||||
| 
 | ||||
| abstract class ContinuationClient<Network, Domain> { | ||||
|     private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf() | ||||
|     private val continuationExists: MutableMap<String, Boolean> = mutableMapOf() | ||||
| 
 | ||||
|     private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true | ||||
| 
 | ||||
|     fun continuationRequest( | ||||
|         prefix: String, | ||||
|         name: String, | ||||
|         requestFunction: (Map<String, String>) -> Single<Network> | ||||
|         requestFunction: (Map<String, String>) -> Single<Network>, | ||||
|     ): Single<List<Domain>> { | ||||
|         val key = "$prefix$name" | ||||
|         return if (hasMorePagesFor(key)) { | ||||
|  | @ -21,9 +21,15 @@ abstract class ContinuationClient<Network, Domain> { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     abstract fun responseMapper(networkResult: Single<Network>, key: String?=null): Single<List<Domain>> | ||||
|     abstract fun responseMapper( | ||||
|         networkResult: Single<Network>, | ||||
|         key: String? = null, | ||||
|     ): Single<List<Domain>> | ||||
| 
 | ||||
|     fun handleContinuationResponse(continuation:Map<String,String>?, key:String?){ | ||||
|     fun handleContinuationResponse( | ||||
|         continuation: Map<String, String>?, | ||||
|         key: String?, | ||||
|     ) { | ||||
|         if (key != null) { | ||||
|             continuationExists[key] = | ||||
|                 continuation?.let { continuation -> | ||||
|  | @ -33,7 +39,10 @@ abstract class ContinuationClient<Network, Domain> { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected fun resetContinuation(prefix: String, category: String) { | ||||
|     protected fun resetContinuation( | ||||
|         prefix: String, | ||||
|         category: String, | ||||
|     ) { | ||||
|         continuationExists.remove("$prefix$category") | ||||
|         continuationStore.remove("$prefix$category") | ||||
|     } | ||||
|  | @ -44,9 +53,11 @@ abstract class ContinuationClient<Network, Domain> { | |||
|      * @param prefix | ||||
|      * @param userName the username | ||||
|      */ | ||||
|     protected fun resetUserContinuation(prefix: String, userName: String) { | ||||
|     protected fun resetUserContinuation( | ||||
|         prefix: String, | ||||
|         userName: String, | ||||
|     ) { | ||||
|         continuationExists.remove("$prefix$userName") | ||||
|         continuationStore.remove("$prefix$userName") | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -7,32 +7,29 @@ import fr.free.nrw.commons.upload.UploadResult | |||
| data class ChunkInfo( | ||||
|     val uploadResult: UploadResult?, | ||||
|     val indexOfNextChunkToUpload: Int, | ||||
|     val totalChunks: Int | ||||
|     val totalChunks: Int, | ||||
| ) : Parcelable { | ||||
|     constructor(parcel: Parcel) : this( | ||||
|         parcel.readParcelable(UploadResult::class.java.classLoader), | ||||
|         parcel.readInt(), | ||||
|         parcel.readInt() | ||||
|         parcel.readInt(), | ||||
|     ) { | ||||
|     } | ||||
| 
 | ||||
|     override fun writeToParcel(parcel: Parcel, flags: Int) { | ||||
|     override fun writeToParcel( | ||||
|         parcel: Parcel, | ||||
|         flags: Int, | ||||
|     ) { | ||||
|         parcel.writeParcelable(uploadResult, flags) | ||||
|         parcel.writeInt(indexOfNextChunkToUpload) | ||||
|         parcel.writeInt(totalChunks) | ||||
|     } | ||||
| 
 | ||||
|     override fun describeContents(): Int { | ||||
|         return 0 | ||||
|     } | ||||
|     override fun describeContents(): Int = 0 | ||||
| 
 | ||||
|     companion object CREATOR : Parcelable.Creator<ChunkInfo> { | ||||
|         override fun createFromParcel(parcel: Parcel): ChunkInfo { | ||||
|             return ChunkInfo(parcel) | ||||
|         } | ||||
|         override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel) | ||||
| 
 | ||||
|         override fun newArray(size: Int): Array<ChunkInfo?> { | ||||
|             return arrayOfNulls(size) | ||||
|         } | ||||
|         override fun newArray(size: Int): Array<ChunkInfo?> = arrayOfNulls(size) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -5,7 +5,6 @@ import android.os.Parcelable | |||
| import androidx.room.Embedded | ||||
| import androidx.room.Entity | ||||
| import androidx.room.PrimaryKey | ||||
| import fr.free.nrw.commons.CommonsApplication | ||||
| import fr.free.nrw.commons.Media | ||||
| import fr.free.nrw.commons.auth.SessionManager | ||||
| import fr.free.nrw.commons.upload.UploadItem | ||||
|  | @ -44,26 +43,23 @@ data class Contribution constructor( | |||
|     var dateCreatedString: String? = null, | ||||
|     var dateModified: Date? = null, | ||||
|     var dateUploadStarted: Date? = null, | ||||
|     var hasInvalidLocation : Int =  0, | ||||
|     var hasInvalidLocation: Int = 0, | ||||
|     var contentUri: Uri? = null, | ||||
|     var countryCode : String? = null, | ||||
|     var imageSHA1 : String? = null, | ||||
|     var countryCode: String? = null, | ||||
|     var imageSHA1: String? = null, | ||||
|     /** | ||||
|      * Number of times a contribution has been retried after a failure | ||||
|      */ | ||||
|     var retries: Int = 0 | ||||
|     var retries: Int = 0, | ||||
| ) : Parcelable { | ||||
| 
 | ||||
|     fun completeWith(media: Media): Contribution { | ||||
|         return copy(pageId = media.pageId, media = media, state = STATE_COMPLETED) | ||||
|     } | ||||
|     fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED) | ||||
| 
 | ||||
|     constructor( | ||||
|         item: UploadItem, | ||||
|         sessionManager: SessionManager, | ||||
|         depictedItems: List<DepictedItem>, | ||||
|         categories: List<String>, | ||||
|         imageSHA1: String | ||||
|         imageSHA1: String, | ||||
|     ) : this( | ||||
|         Media( | ||||
|             formatCaptions(item.uploadMediaDetails), | ||||
|  | @ -71,7 +67,7 @@ data class Contribution constructor( | |||
|             item.fileName, | ||||
|             formatDescriptions(item.uploadMediaDetails), | ||||
|             sessionManager.userName, | ||||
|             sessionManager.userName | ||||
|             sessionManager.userName, | ||||
|         ), | ||||
|         localUri = item.mediaUri, | ||||
|         decimalCoords = item.gpsCoords.decimalCoords, | ||||
|  | @ -80,7 +76,7 @@ data class Contribution constructor( | |||
|         wikidataPlace = from(item.place), | ||||
|         contentUri = item.contentUri, | ||||
|         dateCreatedString = item.fileCreatedDateString, | ||||
|         imageSHA1 = imageSHA1 | ||||
|         imageSHA1 = imageSHA1, | ||||
|     ) | ||||
| 
 | ||||
|     /** | ||||
|  | @ -91,9 +87,7 @@ data class Contribution constructor( | |||
|         this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0 | ||||
|     } | ||||
| 
 | ||||
|     fun hasInvalidLocation(): Boolean { | ||||
|         return hasInvalidLocation == 1 | ||||
|     } | ||||
|     fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1 | ||||
| 
 | ||||
|     companion object { | ||||
|         const val STATE_COMPLETED = -1 | ||||
|  | @ -107,7 +101,8 @@ data class Contribution constructor( | |||
|          * @param uploadMediaDetails list of media Details | ||||
|          */ | ||||
|         fun formatCaptions(uploadMediaDetails: List<UploadMediaDetail>) = | ||||
|             uploadMediaDetails.associate { it.languageCode!! to it.captionText } | ||||
|             uploadMediaDetails | ||||
|                 .associate { it.languageCode!! to it.captionText } | ||||
|                 .filter { it.value.isNotBlank() } | ||||
| 
 | ||||
|         /** | ||||
|  | @ -117,19 +112,15 @@ data class Contribution constructor( | |||
|          * @return a string with the pattern of {{en|1=descriptionText}} | ||||
|          */ | ||||
|         fun formatDescriptions(descriptions: List<UploadMediaDetail>) = | ||||
|             descriptions.filter { it.descriptionText.isNotEmpty() } | ||||
|             descriptions | ||||
|                 .filter { it.descriptionText.isNotEmpty() } | ||||
|                 .joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" } | ||||
|     } | ||||
| 
 | ||||
|     val fileKey : String? get() = chunkInfo?.uploadResult?.filekey | ||||
|     val fileKey: String? get() = chunkInfo?.uploadResult?.filekey | ||||
|     val localUriPath: File? get() = localUri?.path?.let { File(it) } | ||||
| 
 | ||||
|     fun isCompleted(): Boolean { | ||||
|         return chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload | ||||
|     } | ||||
| 
 | ||||
|     fun dateUploadStartedInMillis(): Long { | ||||
|         return dateUploadStarted!!.time | ||||
|     } | ||||
|     fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload | ||||
| 
 | ||||
|     fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time | ||||
| } | ||||
|  |  | |||
|  | @ -14,88 +14,90 @@ import javax.inject.Named | |||
|  * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that | ||||
|  * is triggered for various boundary conditions in the list | ||||
|  */ | ||||
| class ContributionBoundaryCallback @Inject constructor( | ||||
|     private val repository: ContributionsRepository, | ||||
|     private val sessionManager: SessionManager, | ||||
|     private val mediaClient: MediaClient, | ||||
|     @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler | ||||
| ) : BoundaryCallback<Contribution>() { | ||||
|     private val compositeDisposable: CompositeDisposable = CompositeDisposable() | ||||
|     var userName: String? = null | ||||
| class ContributionBoundaryCallback | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val repository: ContributionsRepository, | ||||
|         private val sessionManager: SessionManager, | ||||
|         private val mediaClient: MediaClient, | ||||
|         @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, | ||||
|     ) : BoundaryCallback<Contribution>() { | ||||
|         private val compositeDisposable: CompositeDisposable = CompositeDisposable() | ||||
|         var userName: String? = null | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * It is triggered when the list has no items User's Contributions are then fetched from the | ||||
|      * network | ||||
|      */ | ||||
|     override fun onZeroItemsLoaded() { | ||||
|         if (sessionManager.userName != null) { | ||||
|             mediaClient.resetUserNameContinuation(sessionManager.userName!!) | ||||
|         /** | ||||
|          * It is triggered when the list has no items User's Contributions are then fetched from the | ||||
|          * network | ||||
|          */ | ||||
|         override fun onZeroItemsLoaded() { | ||||
|             if (sessionManager.userName != null) { | ||||
|                 mediaClient.resetUserNameContinuation(sessionManager.userName!!) | ||||
|             } | ||||
|             fetchContributions() | ||||
|         } | ||||
|         fetchContributions() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * It is triggered when the user scrolls to the top of the list | ||||
|      * */ | ||||
|     override fun onItemAtFrontLoaded(itemAtFront: Contribution) { | ||||
|         /** | ||||
|          * It is triggered when the user scrolls to the top of the list | ||||
|          * */ | ||||
|         override fun onItemAtFrontLoaded(itemAtFront: Contribution) { | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
|         /** | ||||
|          * It is triggered when the user scrolls to the end of the list. User's Contributions are then | ||||
|          * fetched from the network | ||||
|          */ | ||||
|         override fun onItemAtEndLoaded(itemAtEnd: Contribution) { | ||||
|             fetchContributions() | ||||
|         } | ||||
| 
 | ||||
|     /** | ||||
|      * It is triggered when the user scrolls to the end of the list. User's Contributions are then | ||||
|      * fetched from the network | ||||
|      */ | ||||
|     override fun onItemAtEndLoaded(itemAtEnd: Contribution) { | ||||
|         fetchContributions() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches contributions using the MediaWiki API | ||||
|      */ | ||||
|     private fun fetchContributions() { | ||||
|         if (sessionManager.userName != null) { | ||||
|             userName?.let { userName -> | ||||
|                 mediaClient.getMediaListForUser(userName) | ||||
|                     .map { mediaList -> | ||||
|                         mediaList.map { media -> | ||||
|                             Contribution(media = media, state = Contribution.STATE_COMPLETED) | ||||
|                         } | ||||
|                     } | ||||
|                     .subscribeOn(ioThreadScheduler) | ||||
|                     .subscribe(::saveContributionsToDB) { error: Throwable -> | ||||
|                         Timber.e( | ||||
|                             "Failed to fetch contributions: %s", | ||||
|                             error.message | ||||
|         /** | ||||
|          * Fetches contributions using the MediaWiki API | ||||
|          */ | ||||
|         private fun fetchContributions() { | ||||
|             if (sessionManager.userName != null) { | ||||
|                 userName | ||||
|                     ?.let { userName -> | ||||
|                         mediaClient | ||||
|                             .getMediaListForUser(userName) | ||||
|                             .map { mediaList -> | ||||
|                                 mediaList.map { media -> | ||||
|                                     Contribution(media = media, state = Contribution.STATE_COMPLETED) | ||||
|                                 } | ||||
|                             }.subscribeOn(ioThreadScheduler) | ||||
|                             .subscribe(::saveContributionsToDB) { error: Throwable -> | ||||
|                                 Timber.e( | ||||
|                                     "Failed to fetch contributions: %s", | ||||
|                                     error.message, | ||||
|                                 ) | ||||
|                             } | ||||
|                     }?.let { | ||||
|                         compositeDisposable.add( | ||||
|                             it, | ||||
|                         ) | ||||
|                     } | ||||
|             }?.let { | ||||
|                 compositeDisposable.add( | ||||
|                     it | ||||
|                 ) | ||||
|             } else { | ||||
|                 compositeDisposable.clear() | ||||
|             } | ||||
|         }else { | ||||
|             compositeDisposable.clear() | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Saves the contributions the the local DB | ||||
|          */ | ||||
|         private fun saveContributionsToDB(contributions: List<Contribution>) { | ||||
|             compositeDisposable.add( | ||||
|                 repository | ||||
|                     .save(contributions) | ||||
|                     .subscribeOn(ioThreadScheduler) | ||||
|                     .subscribe { longs: List<Long?>? -> | ||||
|                         repository["last_fetch_timestamp"] = System.currentTimeMillis() | ||||
|                     }, | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Clean up | ||||
|          */ | ||||
|         fun dispose() { | ||||
|             compositeDisposable.dispose() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Saves the contributions the the local DB | ||||
|      */ | ||||
|     private fun saveContributionsToDB(contributions: List<Contribution>) { | ||||
|         compositeDisposable.add( | ||||
|             repository.save(contributions) | ||||
|                 .subscribeOn(ioThreadScheduler) | ||||
|                 .subscribe { longs: List<Long?>? -> | ||||
|                     repository["last_fetch_timestamp"] = System.currentTimeMillis() | ||||
|                 } | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clean up | ||||
|      */ | ||||
|     fun dispose() { | ||||
|         compositeDisposable.dispose() | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -12,62 +12,61 @@ import javax.inject.Named | |||
| /** | ||||
|  * Data-Source which acts as mediator for contributions-data from the API | ||||
|  */ | ||||
| class ContributionsRemoteDataSource @Inject constructor( | ||||
|     private val mediaClient: MediaClient, | ||||
|     @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler | ||||
| ) : ItemKeyedDataSource<Int, Contribution>() { | ||||
|     private val compositeDisposable: CompositeDisposable = CompositeDisposable() | ||||
|     var userName: String? = null | ||||
| class ContributionsRemoteDataSource | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val mediaClient: MediaClient, | ||||
|         @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, | ||||
|     ) : ItemKeyedDataSource<Int, Contribution>() { | ||||
|         private val compositeDisposable: CompositeDisposable = CompositeDisposable() | ||||
|         var userName: String? = null | ||||
| 
 | ||||
|     override fun loadInitial( | ||||
|         params: LoadInitialParams<Int>, | ||||
|         callback: LoadInitialCallback<Contribution> | ||||
|     ) { | ||||
|         fetchContributions(callback) | ||||
|         override fun loadInitial( | ||||
|             params: LoadInitialParams<Int>, | ||||
|             callback: LoadInitialCallback<Contribution>, | ||||
|         ) { | ||||
|             fetchContributions(callback) | ||||
|         } | ||||
| 
 | ||||
|         override fun loadAfter( | ||||
|             params: LoadParams<Int>, | ||||
|             callback: LoadCallback<Contribution>, | ||||
|         ) { | ||||
|             fetchContributions(callback) | ||||
|         } | ||||
| 
 | ||||
|         override fun loadBefore( | ||||
|             params: LoadParams<Int>, | ||||
|             callback: LoadCallback<Contribution>, | ||||
|         ) { | ||||
|         } | ||||
| 
 | ||||
|         override fun getKey(item: Contribution): Int = item.pageId.hashCode() | ||||
| 
 | ||||
|         /** | ||||
|          * Fetches contributions using the MediaWiki API | ||||
|          */ | ||||
|         private fun fetchContributions(callback: LoadCallback<Contribution>) { | ||||
|             compositeDisposable.add( | ||||
|                 mediaClient | ||||
|                     .getMediaListForUser(userName!!) | ||||
|                     .map { mediaList -> | ||||
|                         mediaList.map { | ||||
|                             Contribution(media = it, state = Contribution.STATE_COMPLETED) | ||||
|                         } | ||||
|                     }.subscribeOn(ioThreadScheduler) | ||||
|                     .subscribe({ | ||||
|                         callback.onResult(it) | ||||
|                     }) { error: Throwable -> | ||||
|                         Timber.e( | ||||
|                             "Failed to fetch contributions: %s", | ||||
|                             error.message, | ||||
|                         ) | ||||
|                     }, | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         fun dispose() { | ||||
|             compositeDisposable.dispose() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun loadAfter( | ||||
|         params: LoadParams<Int>, | ||||
|         callback: LoadCallback<Contribution> | ||||
|     ) { | ||||
|         fetchContributions(callback) | ||||
|     } | ||||
| 
 | ||||
|     override fun loadBefore( | ||||
|         params: LoadParams<Int>, | ||||
|         callback: LoadCallback<Contribution> | ||||
|     ) { | ||||
|     } | ||||
| 
 | ||||
|     override fun getKey(item: Contribution): Int { | ||||
|         return item.pageId.hashCode() | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches contributions using the MediaWiki API | ||||
|      */ | ||||
|     private fun fetchContributions(callback: LoadCallback<Contribution>) { | ||||
|         compositeDisposable.add( | ||||
|             mediaClient.getMediaListForUser(userName!!) | ||||
|                 .map { mediaList -> | ||||
|                     mediaList.map { | ||||
|                         Contribution(media = it, state = Contribution.STATE_COMPLETED) | ||||
|                     } | ||||
|                 } | ||||
|                 .subscribeOn(ioThreadScheduler) | ||||
|                 .subscribe({ | ||||
|                     callback.onResult(it) | ||||
|                 }) { error: Throwable -> | ||||
|                     Timber.e( | ||||
|                         "Failed to fetch contributions: %s", | ||||
|                         error.message | ||||
|                     ) | ||||
|                 } | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun dispose() { | ||||
|         compositeDisposable.dispose() | ||||
|     } | ||||
| } | ||||
|  | @ -13,26 +13,30 @@ import fr.free.nrw.commons.databinding.DialogAddToWikipediaInstructionsBinding | |||
|  * Dialog fragment for displaying instructions for editing wikipedia | ||||
|  */ | ||||
| class WikipediaInstructionsDialogFragment : DialogFragment() { | ||||
| 
 | ||||
|     var callback: Callback? = null | ||||
| 
 | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ) = DialogAddToWikipediaInstructionsBinding.inflate(inflater, container, false).apply { | ||||
|         val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) | ||||
|         tvWikicode.setText(contribution?.media?.wikiCode) | ||||
|         instructionsCancel.setOnClickListener { dismiss() } | ||||
|         instructionsConfirm.setOnClickListener { | ||||
|             callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) | ||||
|         } | ||||
|     }.root | ||||
|         savedInstanceState: Bundle?, | ||||
|     ) = DialogAddToWikipediaInstructionsBinding | ||||
|         .inflate(inflater, container, false) | ||||
|         .apply { | ||||
|             val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) | ||||
|             tvWikicode.setText(contribution?.media?.wikiCode) | ||||
|             instructionsCancel.setOnClickListener { dismiss() } | ||||
|             instructionsConfirm.setOnClickListener { | ||||
|                 callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) | ||||
|             } | ||||
|         }.root | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|     override fun onViewCreated( | ||||
|         view: View, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         dialog!!.window?.setSoftInputMode( | ||||
|             WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN | ||||
|             WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -40,15 +44,19 @@ class WikipediaInstructionsDialogFragment : DialogFragment() { | |||
|      * Callback for handling confirm button clicked | ||||
|      */ | ||||
|     interface Callback { | ||||
|         fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) | ||||
|         fun onConfirmClicked( | ||||
|             contribution: Contribution?, | ||||
|             copyWikicode: Boolean, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
|         const val ARG_CONTRIBUTION = "contribution" | ||||
| 
 | ||||
|         @JvmStatic | ||||
|         fun newInstance(contribution: Contribution) = WikipediaInstructionsDialogFragment().apply { | ||||
|             arguments = bundleOf(ARG_CONTRIBUTION to contribution) | ||||
|         } | ||||
|         fun newInstance(contribution: Contribution) = | ||||
|             WikipediaInstructionsDialogFragment().apply { | ||||
|                 arguments = bundleOf(ARG_CONTRIBUTION to contribution) | ||||
|             } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -2,17 +2,15 @@ package fr.free.nrw.commons.customselector.database | |||
| 
 | ||||
| import androidx.room.* | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Dao class for Not For Upload | ||||
|  */ | ||||
| @Dao | ||||
| abstract class NotForUploadStatusDao { | ||||
| 
 | ||||
|     /** | ||||
|      * Insert into Not For Upload status. | ||||
|      */ | ||||
|     @Insert( onConflict = OnConflictStrategy.REPLACE ) | ||||
|     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||
|     abstract suspend fun insert(notForUploadStatus: NotForUploadStatus) | ||||
| 
 | ||||
|     /** | ||||
|  | @ -25,33 +23,27 @@ abstract class NotForUploadStatusDao { | |||
|      * Query Not For Upload status with image sha1. | ||||
|      */ | ||||
|     @Query("SELECT * FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") | ||||
|     abstract suspend fun getFromImageSHA1(imageSHA1 : String) : NotForUploadStatus? | ||||
|     abstract suspend fun getFromImageSHA1(imageSHA1: String): NotForUploadStatus? | ||||
| 
 | ||||
|     /** | ||||
|      * Asynchronous image sha1 query. | ||||
|      */ | ||||
|     suspend fun getNotForUploadFromImageSHA1(imageSHA1: String):NotForUploadStatus? { | ||||
|         return getFromImageSHA1(imageSHA1) | ||||
|     } | ||||
|     suspend fun getNotForUploadFromImageSHA1(imageSHA1: String): NotForUploadStatus? = getFromImageSHA1(imageSHA1) | ||||
| 
 | ||||
|     /** | ||||
|      * Deletion Not For Upload status with image sha1. | ||||
|      */ | ||||
|     @Query("DELETE FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") | ||||
|     abstract suspend fun deleteWithImageSHA1(imageSHA1 : String) | ||||
|     abstract suspend fun deleteWithImageSHA1(imageSHA1: String) | ||||
| 
 | ||||
|     /** | ||||
|      * Asynchronous image sha1 deletion. | ||||
|      */ | ||||
|     suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) { | ||||
|         return deleteWithImageSHA1(imageSHA1) | ||||
|     } | ||||
|     suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) = deleteWithImageSHA1(imageSHA1) | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether the imageSHA1 is present in database | ||||
|      */ | ||||
|     @Query("SELECT COUNT() FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") | ||||
|     abstract suspend fun find(imageSHA1 : String): Int | ||||
|     abstract suspend fun find(imageSHA1: String): Int | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,10 +7,9 @@ import androidx.room.* | |||
|  */ | ||||
| @Entity(tableName = "images_not_for_upload_table") | ||||
| data class NotForUploadStatus( | ||||
| 
 | ||||
|     /** | ||||
|      * Original image sha1. | ||||
|      */ | ||||
|     @PrimaryKey | ||||
|     val imageSHA1 : String | ||||
|     val imageSHA1: String, | ||||
| ) | ||||
|  |  | |||
|  | @ -8,11 +8,10 @@ import java.util.* | |||
|  */ | ||||
| @Dao | ||||
| abstract class UploadedStatusDao { | ||||
| 
 | ||||
|     /** | ||||
|      * Insert into uploaded status. | ||||
|      */ | ||||
|     @Insert( onConflict = OnConflictStrategy.REPLACE ) | ||||
|     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||
|     abstract suspend fun insert(uploadedStatus: UploadedStatus) | ||||
| 
 | ||||
|     /** | ||||
|  | @ -31,13 +30,13 @@ abstract class UploadedStatusDao { | |||
|      * Query uploaded status with image sha1. | ||||
|      */ | ||||
|     @Query("SELECT * FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) ") | ||||
|     abstract suspend fun getFromImageSHA1(imageSHA1 : String) : UploadedStatus? | ||||
|     abstract suspend fun getFromImageSHA1(imageSHA1: String): UploadedStatus? | ||||
| 
 | ||||
|     /** | ||||
|      * Query uploaded status with modified image sha1. | ||||
|      */ | ||||
|     @Query("SELECT * FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) ") | ||||
|     abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1 : String) : UploadedStatus? | ||||
|     abstract suspend fun getFromModifiedImageSHA1(modifiedImageSHA1: String): UploadedStatus? | ||||
| 
 | ||||
|     /** | ||||
|      * Asynchronous insert into uploaded status table. | ||||
|  | @ -51,20 +50,24 @@ abstract class UploadedStatusDao { | |||
|      * Check whether the imageSHA1 is present in database | ||||
|      */ | ||||
|     @Query("SELECT COUNT() FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) AND imageResult = (:imageResult) ") | ||||
|     abstract suspend fun findByImageSHA1(imageSHA1 : String, imageResult: Boolean): Int | ||||
|     abstract suspend fun findByImageSHA1( | ||||
|         imageSHA1: String, | ||||
|         imageResult: Boolean, | ||||
|     ): Int | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether the modifiedImageSHA1 is present in database | ||||
|      */ | ||||
|     @Query("SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ") | ||||
|     abstract suspend fun findByModifiedImageSHA1(modifiedImageSHA1 : String, | ||||
|                                                  modifiedImageResult: Boolean): Int | ||||
|     @Query( | ||||
|         "SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ", | ||||
|     ) | ||||
|     abstract suspend fun findByModifiedImageSHA1( | ||||
|         modifiedImageSHA1: String, | ||||
|         modifiedImageResult: Boolean, | ||||
|     ): Int | ||||
| 
 | ||||
|     /** | ||||
|      * Asynchronous image sha1 query. | ||||
|      */ | ||||
|     suspend fun getUploadedFromImageSHA1(imageSHA1: String):UploadedStatus? { | ||||
|         return getFromImageSHA1(imageSHA1) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|     suspend fun getUploadedFromImageSHA1(imageSHA1: String): UploadedStatus? = getFromImageSHA1(imageSHA1) | ||||
| } | ||||
|  |  | |||
|  | @ -10,30 +10,25 @@ import java.util.* | |||
|  */ | ||||
| @Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)]) | ||||
| data class UploadedStatus( | ||||
| 
 | ||||
|     /** | ||||
|      * Original image sha1. | ||||
|      */ | ||||
|     @PrimaryKey | ||||
|     val imageSHA1 : String, | ||||
| 
 | ||||
|     val imageSHA1: String, | ||||
|     /** | ||||
|      * Modified image sha1 (after exif changes). | ||||
|      */ | ||||
|     val modifiedImageSHA1 : String, | ||||
| 
 | ||||
|     val modifiedImageSHA1: String, | ||||
|     /** | ||||
|      * imageSHA1 query result from API. | ||||
|      */ | ||||
|     var imageResult : Boolean, | ||||
| 
 | ||||
|     var imageResult: Boolean, | ||||
|     /** | ||||
|      * modifiedImageSHA1 query result from API. | ||||
|      */ | ||||
|     var modifiedImageResult : Boolean, | ||||
| 
 | ||||
|     var modifiedImageResult: Boolean, | ||||
|     /** | ||||
|      * lastUpdated for data validation. | ||||
|      */ | ||||
|     var lastUpdated : Date? = null | ||||
|     var lastUpdated: Date? = null, | ||||
| ) | ||||
|  |  | |||
|  | @ -4,12 +4,10 @@ package fr.free.nrw.commons.customselector.helper | |||
|  * Stores constants related to custom image selector | ||||
|  */ | ||||
| object CustomSelectorConstants { | ||||
| 
 | ||||
|     const val BUCKET_ID = "bucket_id" | ||||
|     const val TOTAL_SELECTED_IMAGES = "total_selected_images" | ||||
|     const val PRESENT_POSITION = "present_position" | ||||
|     const val NEW_SELECTED_IMAGES = "new_selected_images" | ||||
|     const val SHOULD_REFRESH = "should_refresh" | ||||
|     const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch" | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image | |||
|  * Image Helper object, includes all the static functions and variables required by custom selector. | ||||
|  */ | ||||
| object ImageHelper { | ||||
| 
 | ||||
|     /** | ||||
|      * Custom selector preference key | ||||
|      */ | ||||
|  | @ -39,7 +38,10 @@ object ImageHelper { | |||
|     /** | ||||
|      * Filters the images based on the given bucketId (folder) | ||||
|      */ | ||||
|     fun filterImages(images: ArrayList<Image>, bukketId: Long?): ArrayList<Image> { | ||||
|     fun filterImages( | ||||
|         images: ArrayList<Image>, | ||||
|         bukketId: Long?, | ||||
|     ): ArrayList<Image> { | ||||
|         if (bukketId == null) return images | ||||
| 
 | ||||
|         val filteredImages = arrayListOf<Image>() | ||||
|  | @ -54,30 +56,37 @@ object ImageHelper { | |||
|     /** | ||||
|      * getIndex: Returns the index of image in given list. | ||||
|      */ | ||||
|     fun getIndex(list: ArrayList<Image>, image: Image): Int { | ||||
|         return list.indexOf(image) | ||||
|     } | ||||
|     fun getIndex( | ||||
|         list: ArrayList<Image>, | ||||
|         image: Image, | ||||
|     ): Int = list.indexOf(image) | ||||
| 
 | ||||
|     /** | ||||
|      * getIndex: Returns the index of image in given list. | ||||
|      */ | ||||
|     fun getIndexFromId(list: ArrayList<Image>, imageId: Long): Int { | ||||
|         for(i in list){ | ||||
|             if(i.id == imageId) | ||||
|     fun getIndexFromId( | ||||
|         list: ArrayList<Image>, | ||||
|         imageId: Long, | ||||
|     ): Int { | ||||
|         for (i in list) { | ||||
|             if (i.id == imageId) { | ||||
|                 return list.indexOf(i) | ||||
|             } | ||||
|         } | ||||
|         return 0; | ||||
|         return 0 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the list of indices from the master list. | ||||
|      */ | ||||
|     fun getIndexList(list: ArrayList<Image>, masterList: ArrayList<Image>): ArrayList<Int> { | ||||
| 
 | ||||
|          // Can be optimised as masterList is sorted by time. | ||||
|     fun getIndexList( | ||||
|         list: ArrayList<Image>, | ||||
|         masterList: ArrayList<Image>, | ||||
|     ): ArrayList<Int> { | ||||
|         // Can be optimised as masterList is sorted by time. | ||||
| 
 | ||||
|         val indexes = arrayListOf<Int>() | ||||
|         for(image in list) { | ||||
|         for (image in list) { | ||||
|             val index = getIndex(masterList, image) | ||||
|             if (index == -1) { | ||||
|                 continue | ||||
|  | @ -86,4 +95,4 @@ object ImageHelper { | |||
|         } | ||||
|         return indexes | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -8,17 +8,19 @@ import kotlin.math.abs | |||
| /** | ||||
|  * Class for detecting swipe gestures | ||||
|  */ | ||||
| open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { | ||||
| 
 | ||||
| open class OnSwipeTouchListener( | ||||
|     context: Context?, | ||||
| ) : View.OnTouchListener { | ||||
|     private val gestureDetector: GestureDetector | ||||
| 
 | ||||
|     private val SWIPE_THRESHOLD_HEIGHT = (getScreenResolution(context!!)).second / 3 | ||||
|     private val SWIPE_THRESHOLD_WIDTH = (getScreenResolution(context!!)).first / 3 | ||||
|     private val SWIPE_VELOCITY_THRESHOLD = 1000 | ||||
| 
 | ||||
|     override fun onTouch(view: View?, motionEvent: MotionEvent): Boolean { | ||||
|         return gestureDetector.onTouchEvent(motionEvent) | ||||
|     } | ||||
|     override fun onTouch( | ||||
|         view: View?, | ||||
|         motionEvent: MotionEvent, | ||||
|     ): Boolean = gestureDetector.onTouchEvent(motionEvent) | ||||
| 
 | ||||
|     fun getScreenResolution(context: Context): Pair<Int, Int> { | ||||
|         val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager | ||||
|  | @ -31,10 +33,7 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { | |||
|     } | ||||
| 
 | ||||
|     inner class GestureListener : GestureDetector.SimpleOnGestureListener() { | ||||
| 
 | ||||
|         override fun onDown(e: MotionEvent): Boolean { | ||||
|             return true | ||||
|         } | ||||
|         override fun onDown(e: MotionEvent): Boolean = true | ||||
| 
 | ||||
|         /** | ||||
|          * Detects the gestures | ||||
|  | @ -43,14 +42,16 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { | |||
|             event1: MotionEvent?, | ||||
|             event2: MotionEvent, | ||||
|             velocityX: Float, | ||||
|             velocityY: Float | ||||
|             velocityY: Float, | ||||
|         ): Boolean { | ||||
|             try { | ||||
|                 val diffY: Float = event2.y - (event1?.y ?: event2.y) | ||||
|                 val diffX: Float = event2.x - (event1?.x ?: event2.x) | ||||
|                 if (abs(diffX) > abs(diffY)) { | ||||
|                     if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) > | ||||
|                         SWIPE_VELOCITY_THRESHOLD) { | ||||
|                     if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && | ||||
|                         abs(velocityX) > | ||||
|                         SWIPE_VELOCITY_THRESHOLD | ||||
|                     ) { | ||||
|                         if (diffX > 0) { | ||||
|                             onSwipeRight() | ||||
|                         } else { | ||||
|  | @ -58,8 +59,10 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { | |||
|                         } | ||||
|                     } | ||||
|                 } else { | ||||
|                     if (abs(diffY) > SWIPE_THRESHOLD_HEIGHT && abs(velocityY) > | ||||
|                         SWIPE_VELOCITY_THRESHOLD) { | ||||
|                     if (abs(diffY) > SWIPE_THRESHOLD_HEIGHT && | ||||
|                         abs(velocityY) > | ||||
|                         SWIPE_VELOCITY_THRESHOLD | ||||
|                     ) { | ||||
|                         if (diffY > 0) { | ||||
|                             onSwipeDown() | ||||
|                         } else { | ||||
|  | @ -100,4 +103,4 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener { | |||
|     init { | ||||
|         gestureDetector = GestureDetector(context, GestureListener()) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -4,12 +4,15 @@ package fr.free.nrw.commons.customselector.listeners | |||
|  * Custom Selector Folder Click Listener | ||||
|  */ | ||||
| interface FolderClickListener { | ||||
| 
 | ||||
|     /** | ||||
|      * onFolderClick | ||||
|      * @param folderId : folder id of the folder. | ||||
|      * @param folderName : folder name of the folder. | ||||
|      * @param lastItemId : last scroll position in the folder. | ||||
|      */ | ||||
|     fun onFolderClick(folderId: Long, folderName: String, lastItemId: Long) | ||||
| } | ||||
|     fun onFolderClick( | ||||
|         folderId: Long, | ||||
|         folderName: String, | ||||
|         lastItemId: Long, | ||||
|     ) | ||||
| } | ||||
|  |  | |||
|  | @ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image | |||
|  * responds to the device image query. | ||||
|  */ | ||||
| interface ImageLoaderListener { | ||||
| 
 | ||||
|     /** | ||||
|      * On image loaded | ||||
|      * @param images : queried device images. | ||||
|  | @ -19,4 +18,4 @@ interface ImageLoaderListener { | |||
|      * @param throwable : throwable exception on failure. | ||||
|      */ | ||||
|     fun onFailed(throwable: Throwable) | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -1,19 +1,20 @@ | |||
| package fr.free.nrw.commons.customselector.listeners | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import fr.free.nrw.commons.customselector.model.Image | ||||
| 
 | ||||
| /** | ||||
|  * Custom selector Image select listener | ||||
|  */ | ||||
| interface ImageSelectListener { | ||||
| 
 | ||||
|     /** | ||||
|      * onSelectedImagesChanged | ||||
|      * @param selectedImages : new selected images. | ||||
|      * @param selectedNotForUploadImages : number of selected not for upload images | ||||
|      */ | ||||
|     fun onSelectedImagesChanged(selectedImages: ArrayList<Image>, selectedNotForUploadImages: Int) | ||||
|     fun onSelectedImagesChanged( | ||||
|         selectedImages: ArrayList<Image>, | ||||
|         selectedNotForUploadImages: Int, | ||||
|     ) | ||||
| 
 | ||||
|     /** | ||||
|      * onLongPress | ||||
|  | @ -22,6 +23,6 @@ interface ImageSelectListener { | |||
|     fun onLongPress( | ||||
|         position: Int, | ||||
|         images: ArrayList<Image>, | ||||
|         selectedImages: ArrayList<Image> | ||||
|         selectedImages: ArrayList<Image>, | ||||
|     ) | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -6,5 +6,8 @@ import fr.free.nrw.commons.customselector.model.Image | |||
|  * Interface to pass data between fragment and activity | ||||
|  */ | ||||
| interface PassDataListener { | ||||
|     fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean) | ||||
| } | ||||
|     fun passSelectedImages( | ||||
|         selectedImages: ArrayList<Image>, | ||||
|         shouldRefresh: Boolean, | ||||
|     ) | ||||
| } | ||||
|  |  | |||
|  | @ -8,4 +8,4 @@ interface RefreshUIListener { | |||
|      * Refreshes the data in adapter | ||||
|      */ | ||||
|     fun refresh() | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -6,17 +6,17 @@ package fr.free.nrw.commons.customselector.model | |||
|  */ | ||||
| sealed class CallbackStatus { | ||||
|     /** | ||||
|     IDLE : The callback is idle , doing nothing. | ||||
|      IDLE : The callback is idle , doing nothing. | ||||
|      */ | ||||
|     object IDLE : CallbackStatus() | ||||
| 
 | ||||
|     /** | ||||
|     FETCHING : Fetching images. | ||||
|      FETCHING : Fetching images. | ||||
|      */ | ||||
|     object FETCHING : CallbackStatus() | ||||
| 
 | ||||
|     /** | ||||
|     SUCCESS : Success fetching images. | ||||
|      SUCCESS : Success fetching images. | ||||
|      */ | ||||
|     object SUCCESS : CallbackStatus() | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -5,27 +5,22 @@ package fr.free.nrw.commons.customselector.model | |||
|  */ | ||||
| data class Folder( | ||||
|     /** | ||||
|     bucketId : Unique directory id, eg 540528482 | ||||
|      bucketId : Unique directory id, eg 540528482 | ||||
|      */ | ||||
|     var bucketId: Long, | ||||
| 
 | ||||
|     /** | ||||
|     name : bucket/folder name, eg Camera | ||||
|      name : bucket/folder name, eg Camera | ||||
|      */ | ||||
|     var name: String, | ||||
| 
 | ||||
|     /** | ||||
|     images : folder images, list of all images under this folder. | ||||
|      images : folder images, list of all images under this folder. | ||||
|      */ | ||||
|     var images: ArrayList<Image> = arrayListOf<Image>() | ||||
| 
 | ||||
| 
 | ||||
|     var images: ArrayList<Image> = arrayListOf<Image>(), | ||||
| ) { | ||||
|     /** | ||||
|      * Indicates whether some other object is "equal to" this one. | ||||
|      */ | ||||
|     override fun equals(other: Any?): Boolean { | ||||
| 
 | ||||
|         if (javaClass != other?.javaClass) { | ||||
|             return false | ||||
|         } | ||||
|  | @ -44,4 +39,4 @@ data class Folder( | |||
| 
 | ||||
|         return true | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -9,65 +9,60 @@ import android.os.Parcelable | |||
|  */ | ||||
| data class Image( | ||||
|     /** | ||||
|     id : Unique image id, primary key of image in device, eg 104950 | ||||
|      id : Unique image id, primary key of image in device, eg 104950 | ||||
|      */ | ||||
|     var id: Long, | ||||
| 
 | ||||
|     /** | ||||
|     name : Name of the image with extension, eg CommonsLogo.jpeg | ||||
|      name : Name of the image with extension, eg CommonsLogo.jpeg | ||||
|      */ | ||||
|     var name: String, | ||||
| 
 | ||||
|     /** | ||||
|     uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10) | ||||
|      uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10) | ||||
|      */ | ||||
|     var uri: Uri, | ||||
| 
 | ||||
|     /** | ||||
|     path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg | ||||
|      path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg | ||||
|      */ | ||||
|     var path: String, | ||||
| 
 | ||||
|     /** | ||||
|     bucketId : bucketId of folder, eg 540528482 | ||||
|      bucketId : bucketId of folder, eg 540528482 | ||||
|      */ | ||||
|     var bucketId: Long = 0, | ||||
| 
 | ||||
|     /** | ||||
|     bucketName : name of folder, eg Camera | ||||
|      bucketName : name of folder, eg Camera | ||||
|      */ | ||||
|     var bucketName: String = "", | ||||
| 
 | ||||
|     /** | ||||
|     sha1 : sha1 of original image. | ||||
|      sha1 : sha1 of original image. | ||||
|      */ | ||||
|     var sha1: String = "", | ||||
| 
 | ||||
|     /** | ||||
|      * date: Creation date of the image to show it inside the bubble during bubble scroll. | ||||
|      */ | ||||
|     var date: String = "" | ||||
| 
 | ||||
|     var date: String = "", | ||||
| ) : Parcelable { | ||||
|     /** | ||||
|      default parcelable constructor. | ||||
|      */ | ||||
|     constructor(parcel: Parcel) : | ||||
|         this( | ||||
|             parcel.readLong(), | ||||
|             parcel.readString()!!, | ||||
|             parcel.readParcelable(Uri::class.java.classLoader)!!, | ||||
|             parcel.readString()!!, | ||||
|             parcel.readLong(), | ||||
|             parcel.readString()!!, | ||||
|             parcel.readString()!!, | ||||
|             parcel.readString()!!, | ||||
|         ) | ||||
| 
 | ||||
|     /** | ||||
|     default parcelable constructor. | ||||
|      Write to parcel method. | ||||
|      */ | ||||
|     constructor(parcel: Parcel): | ||||
|             this(parcel.readLong(), | ||||
|                 parcel.readString()!!, | ||||
|                 parcel.readParcelable(Uri::class.java.classLoader)!!, | ||||
|                 parcel.readString()!!, | ||||
|                 parcel.readLong(), | ||||
|                 parcel.readString()!!, | ||||
|                 parcel.readString()!!, | ||||
|                 parcel.readString()!! | ||||
|             ) | ||||
| 
 | ||||
|     /** | ||||
|     Write to parcel method. | ||||
|      */ | ||||
|     override fun writeToParcel(parcel: Parcel, flags: Int) { | ||||
|     override fun writeToParcel( | ||||
|         parcel: Parcel, | ||||
|         flags: Int, | ||||
|     ) { | ||||
|         parcel.writeLong(id) | ||||
|         parcel.writeString(name) | ||||
|         parcel.writeParcelable(uri, flags) | ||||
|  | @ -81,41 +76,38 @@ data class Image( | |||
|     /** | ||||
|      * Describe the kinds of special objects contained in this Parcelable | ||||
|      */ | ||||
|     override fun describeContents(): Int { | ||||
|         return 0 | ||||
|     } | ||||
|     override fun describeContents(): Int = 0 | ||||
| 
 | ||||
|     /** | ||||
|      * Indicates whether some other object is "equal to" this one. | ||||
|      */ | ||||
|     override fun equals(other: Any?): Boolean { | ||||
| 
 | ||||
|         if(javaClass != other?.javaClass) { | ||||
|         if (javaClass != other?.javaClass) { | ||||
|             return false | ||||
|         } | ||||
| 
 | ||||
|         other as Image | ||||
| 
 | ||||
|         if(id != other.id) { | ||||
|             return false; | ||||
|         if (id != other.id) { | ||||
|             return false | ||||
|         } | ||||
|         if(name != other.name) { | ||||
|             return false; | ||||
|         if (name != other.name) { | ||||
|             return false | ||||
|         } | ||||
|         if(uri != other.uri) { | ||||
|             return false; | ||||
|         if (uri != other.uri) { | ||||
|             return false | ||||
|         } | ||||
|         if(path != other.path) { | ||||
|             return false; | ||||
|         if (path != other.path) { | ||||
|             return false | ||||
|         } | ||||
|         if(bucketId != other.bucketId) { | ||||
|             return false; | ||||
|         if (bucketId != other.bucketId) { | ||||
|             return false | ||||
|         } | ||||
|         if(bucketName != other.bucketName) { | ||||
|             return false; | ||||
|         if (bucketName != other.bucketName) { | ||||
|             return false | ||||
|         } | ||||
|         if(sha1 != other.sha1) { | ||||
|             return false; | ||||
|         if (sha1 != other.sha1) { | ||||
|             return false | ||||
|         } | ||||
| 
 | ||||
|         return true | ||||
|  | @ -125,12 +117,8 @@ data class Image( | |||
|      * Parcelable companion object | ||||
|      */ | ||||
|     companion object CREATOR : Parcelable.Creator<Image> { | ||||
|         override fun createFromParcel(parcel: Parcel): Image { | ||||
|             return Image(parcel) | ||||
|         } | ||||
|         override fun createFromParcel(parcel: Parcel): Image = Image(parcel) | ||||
| 
 | ||||
|         override fun newArray(size: Int): Array<Image?> { | ||||
|             return arrayOfNulls(size) | ||||
|         } | ||||
|         override fun newArray(size: Int): Array<Image?> = arrayOfNulls(size) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -7,10 +7,9 @@ data class Result( | |||
|     /** | ||||
|      * CallbackStatus : stores the result status | ||||
|      */ | ||||
|     val status:CallbackStatus, | ||||
| 
 | ||||
|     val status: CallbackStatus, | ||||
|     /** | ||||
|      * Images : images retrieved | ||||
|      */ | ||||
|     val images: ArrayList<Image>) { | ||||
| } | ||||
|     val images: ArrayList<Image>, | ||||
| ) | ||||
|  |  | |||
|  | @ -21,14 +21,11 @@ class FolderAdapter( | |||
|      * Application context. | ||||
|      */ | ||||
|     context: Context, | ||||
| 
 | ||||
|     /** | ||||
|      * Folder Click listener for click events. | ||||
|      */ | ||||
|     private val itemClickListener: FolderClickListener | ||||
| 
 | ||||
|     private val itemClickListener: FolderClickListener, | ||||
| ) : RecyclerViewAdapter<FolderAdapter.FolderViewHolder?>(context) { | ||||
| 
 | ||||
|     /** | ||||
|      * List of folders. | ||||
|      */ | ||||
|  | @ -37,7 +34,10 @@ class FolderAdapter( | |||
|     /** | ||||
|      * Create view holder, returns View holder item. | ||||
|      */ | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FolderViewHolder { | ||||
|     override fun onCreateViewHolder( | ||||
|         parent: ViewGroup, | ||||
|         viewType: Int, | ||||
|     ): FolderViewHolder { | ||||
|         val itemView = inflater.inflate(R.layout.item_custom_selector_folder, parent, false) | ||||
|         return FolderViewHolder(itemView) | ||||
|     } | ||||
|  | @ -45,28 +45,31 @@ class FolderAdapter( | |||
|     /** | ||||
|      * Bind view holder, setup the item view, title, count and click listener | ||||
|      */ | ||||
|     override fun onBindViewHolder(holder: FolderViewHolder, position: Int) { | ||||
|     override fun onBindViewHolder( | ||||
|         holder: FolderViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         val folder = folders[position] | ||||
|         val toBeRemoved = ArrayList<Image>() | ||||
| 
 | ||||
|         for(image in folder.images) { | ||||
|         for (image in folder.images) { | ||||
|             // Remove all the top images that do not exist anymore | ||||
|             if(context.contentResolver.getType(image.uri) == null){ | ||||
|             if (context.contentResolver.getType(image.uri) == null) { | ||||
|                 // File not found | ||||
|                 toBeRemoved.add(image) | ||||
|             } else { | ||||
|                 break | ||||
|             } | ||||
|         } | ||||
|         holder.image.setImageDrawable (null) | ||||
|         holder.image.setImageDrawable(null) | ||||
|         folder.images.removeAll(toBeRemoved) | ||||
|         val count = folder.images.size | ||||
| 
 | ||||
|         if(count == 0 && folders.size > 0) { | ||||
|         if (count == 0 && folders.size > 0) { | ||||
|             // Folder is empty, remove folder from the adapter. | ||||
|             holder.itemView.post{ | ||||
|             holder.itemView.post { | ||||
|                 val updatePosition = folders.indexOf(folder) | ||||
|                 if(updatePosition != -1) { | ||||
|                 if (updatePosition != -1) { | ||||
|                     folders.removeAt(updatePosition) | ||||
|                     notifyItemRemoved(updatePosition) | ||||
|                     notifyItemRangeChanged(updatePosition, folders.size) | ||||
|  | @ -89,9 +92,10 @@ class FolderAdapter( | |||
|     fun init(newFolders: List<Folder>) { | ||||
|         val oldFolderList: MutableList<Folder> = folders | ||||
|         val newFolderList = newFolders.toMutableList() | ||||
|         val diffResult = DiffUtil.calculateDiff( | ||||
|             FoldersDiffCallback(oldFolderList, newFolderList) | ||||
|         ) | ||||
|         val diffResult = | ||||
|             DiffUtil.calculateDiff( | ||||
|                 FoldersDiffCallback(oldFolderList, newFolderList), | ||||
|             ) | ||||
|         folders = newFolderList | ||||
|         diffResult.dispatchUpdatesTo(this) | ||||
|     } | ||||
|  | @ -99,15 +103,14 @@ class FolderAdapter( | |||
|     /** | ||||
|      * returns item count. | ||||
|      */ | ||||
|     override fun getItemCount(): Int { | ||||
|         return folders.size | ||||
|     } | ||||
|     override fun getItemCount(): Int = folders.size | ||||
| 
 | ||||
|     /** | ||||
|      * Folder view holder. | ||||
|      */ | ||||
|     class FolderViewHolder(itemView:View) : RecyclerView.ViewHolder(itemView) { | ||||
| 
 | ||||
|     class FolderViewHolder( | ||||
|         itemView: View, | ||||
|     ) : RecyclerView.ViewHolder(itemView) { | ||||
|         /** | ||||
|          * Folder thumbnail image view. | ||||
|          */ | ||||
|  | @ -129,37 +132,33 @@ class FolderAdapter( | |||
|      */ | ||||
|     class FoldersDiffCallback( | ||||
|         var oldFolders: MutableList<Folder>, | ||||
|         var newFolders: MutableList<Folder> | ||||
|         var newFolders: MutableList<Folder>, | ||||
|     ) : DiffUtil.Callback() { | ||||
|         /** | ||||
|          * Returns the size of the old list. | ||||
|          */ | ||||
|         override fun getOldListSize(): Int { | ||||
|             return oldFolders.size | ||||
|         } | ||||
|         override fun getOldListSize(): Int = oldFolders.size | ||||
| 
 | ||||
|         /** | ||||
|          * Returns the size of the new list. | ||||
|          */ | ||||
|         override fun getNewListSize(): Int { | ||||
|             return newFolders.size | ||||
|         } | ||||
|         override fun getNewListSize(): Int = newFolders.size | ||||
| 
 | ||||
|         /** | ||||
|          * Called by the DiffUtil to decide whether two object represent the same Item. | ||||
|          */ | ||||
|         override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { | ||||
|             return oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId | ||||
|         } | ||||
|         override fun areItemsTheSame( | ||||
|             oldItemPosition: Int, | ||||
|             newItemPosition: Int, | ||||
|         ): Boolean = oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId | ||||
| 
 | ||||
|         /** | ||||
|          * Called by the DiffUtil when it wants to check whether two items have the same data. | ||||
|          * DiffUtil uses this information to detect if the contents of an item has changed. | ||||
|          */ | ||||
|         override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { | ||||
|             return oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) | ||||
|         } | ||||
| 
 | ||||
|         override fun areContentsTheSame( | ||||
|             oldItemPosition: Int, | ||||
|             newItemPosition: Int, | ||||
|         ): Boolean = oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -5,7 +5,6 @@ import android.content.SharedPreferences | |||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.ImageView | ||||
| import android.widget.TextView | ||||
| import android.widget.Toast | ||||
| import androidx.constraintlayout.widget.Group | ||||
| import androidx.recyclerview.widget.DiffUtil | ||||
|  | @ -32,20 +31,16 @@ class ImageAdapter( | |||
|      * Application Context. | ||||
|      */ | ||||
|     context: Context, | ||||
| 
 | ||||
|     /** | ||||
|      * Image select listener for click events on image. | ||||
|      */ | ||||
|     private var imageSelectListener: ImageSelectListener, | ||||
| 
 | ||||
|     /** | ||||
|      * ImageLoader queries images. | ||||
|      */ | ||||
|     private var imageLoader: ImageLoader | ||||
| ): | ||||
| 
 | ||||
|     RecyclerViewAdapter<ImageAdapter.ImageViewHolder>(context), FastScrollRecyclerView.SectionedAdapter { | ||||
| 
 | ||||
|     private var imageLoader: ImageLoader, | ||||
| ) : RecyclerViewAdapter<ImageAdapter.ImageViewHolder>(context), | ||||
|     FastScrollRecyclerView.SectionedAdapter { | ||||
|     /** | ||||
|      * ImageSelectedOrUpdated payload class. | ||||
|      */ | ||||
|  | @ -106,14 +101,17 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Coroutine Dispatchers and Scope. | ||||
|      */ | ||||
|     private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default | ||||
|     private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO | ||||
|     private val scope : CoroutineScope = MainScope() | ||||
|     private var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default | ||||
|     private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO | ||||
|     private val scope: CoroutineScope = MainScope() | ||||
| 
 | ||||
|     /** | ||||
|      * Create View holder. | ||||
|      */ | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { | ||||
|     override fun onCreateViewHolder( | ||||
|         parent: ViewGroup, | ||||
|         viewType: Int, | ||||
|     ): ImageViewHolder { | ||||
|         val itemView = inflater.inflate(R.layout.item_custom_selector_image, parent, false) | ||||
|         return ImageViewHolder(itemView) | ||||
|     } | ||||
|  | @ -121,10 +119,15 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Bind View holder, load image, selected view, click listeners. | ||||
|      */ | ||||
|     override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { | ||||
|         if(images.size == 0) { return } | ||||
|         var image=images[position] | ||||
|         holder.image.setImageDrawable (null) | ||||
|     override fun onBindViewHolder( | ||||
|         holder: ImageViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         if (images.size == 0) { | ||||
|             return | ||||
|         } | ||||
|         var image = images[position] | ||||
|         holder.image.setImageDrawable(null) | ||||
|         if (context.contentResolver.getType(image.uri) == null) { | ||||
|             // Image does not exist anymore, update adapter. | ||||
|             holder.itemView.post { | ||||
|  | @ -140,18 +143,19 @@ class ImageAdapter( | |||
|                 sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) | ||||
| 
 | ||||
|             // Getting selected index when switch is on | ||||
|             val selectedIndex: Int = if (showAlreadyActionedImages) { | ||||
|                 ImageHelper.getIndex(selectedImages, image) | ||||
|             val selectedIndex: Int = | ||||
|                 if (showAlreadyActionedImages) { | ||||
|                     ImageHelper.getIndex(selectedImages, image) | ||||
| 
 | ||||
|                 // Getting selected index when switch is off | ||||
|             } else if (actionableImagesMap.size > position) { | ||||
|                 ImageHelper | ||||
|                     .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) | ||||
|                     // Getting selected index when switch is off | ||||
|                 } else if (actionableImagesMap.size > position) { | ||||
|                     ImageHelper | ||||
|                         .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) | ||||
| 
 | ||||
|                 // For any other case return -1 | ||||
|             } else { | ||||
|                 -1 | ||||
|             } | ||||
|                     // For any other case return -1 | ||||
|                 } else { | ||||
|                     -1 | ||||
|                 } | ||||
| 
 | ||||
|             val isSelected = selectedIndex != -1 | ||||
|             if (isSelected) { | ||||
|  | @ -160,7 +164,11 @@ class ImageAdapter( | |||
|                 holder.itemUnselected() | ||||
|             } | ||||
|             imageLoader.queryAndSetView( | ||||
|                 holder, image, ioDispatcher, defaultDispatcher ,uploadingContributionList | ||||
|                 holder, | ||||
|                 image, | ||||
|                 ioDispatcher, | ||||
|                 defaultDispatcher, | ||||
|                 uploadingContributionList, | ||||
|             ) | ||||
|             scope.launch { | ||||
|                 val sharedPreferences: SharedPreferences = | ||||
|  | @ -173,22 +181,28 @@ class ImageAdapter( | |||
|                     if (!alreadyAddedPositions.contains(position)) { | ||||
|                         processThumbnailForActionedImage(holder, position, uploadingContributionList) | ||||
| 
 | ||||
|                     // If the position is already visited, that means the image is already present | ||||
|                     // inside map, so it will fetch the image from the map and load in the holder | ||||
|                         // If the position is already visited, that means the image is already present | ||||
|                         // inside map, so it will fetch the image from the map and load in the holder | ||||
|                     } else { | ||||
|                         val actionableImages: List<Image> = ArrayList(actionableImagesMap.values) | ||||
|                         if(actionableImages.size > position) { | ||||
|                         if (actionableImages.size > position) { | ||||
|                             image = actionableImages[position] | ||||
|                             Glide.with(holder.image).load(image.uri) | ||||
|                                 .thumbnail(0.3f).into(holder.image) | ||||
|                             Glide | ||||
|                                 .with(holder.image) | ||||
|                                 .load(image.uri) | ||||
|                                 .thumbnail(0.3f) | ||||
|                                 .into(holder.image) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                 // If switch is turned off, it just fetches the image from all images without any | ||||
|                 // further operations | ||||
|                     // If switch is turned off, it just fetches the image from all images without any | ||||
|                     // further operations | ||||
|                 } else { | ||||
|                     Glide.with(holder.image).load(image.uri) | ||||
|                         .thumbnail(0.3f).into(holder.image) | ||||
|                     Glide | ||||
|                         .with(holder.image) | ||||
|                         .load(image.uri) | ||||
|                         .thumbnail(0.3f) | ||||
|                         .into(holder.image) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|  | @ -210,12 +224,16 @@ class ImageAdapter( | |||
|     suspend fun processThumbnailForActionedImage( | ||||
|         holder: ImageViewHolder, | ||||
|         position: Int, | ||||
|         uploadingContributionList: List<Contribution> | ||||
|         uploadingContributionList: List<Contribution>, | ||||
|     ) { | ||||
|         val next = imageLoader.nextActionableImage( | ||||
|             allImages, ioDispatcher, defaultDispatcher, | ||||
|             nextImagePosition, uploadingContributionList | ||||
|         ) | ||||
|         val next = | ||||
|             imageLoader.nextActionableImage( | ||||
|                 allImages, | ||||
|                 ioDispatcher, | ||||
|                 defaultDispatcher, | ||||
|                 nextImagePosition, | ||||
|                 uploadingContributionList, | ||||
|             ) | ||||
| 
 | ||||
|         // If next actionable image is found, saves it, as the the search for | ||||
|         // finding next actionable image will start from this position | ||||
|  | @ -229,8 +247,11 @@ class ImageAdapter( | |||
|                 actionableImagesMap[next] = allImages[next] | ||||
|                 alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) | ||||
|                 imagePositionAsPerIncreasingOrder++ | ||||
|                 Glide.with(holder.image).load(allImages[next].uri) | ||||
|                     .thumbnail(0.3f).into(holder.image) | ||||
|                 Glide | ||||
|                     .with(holder.image) | ||||
|                     .load(allImages[next].uri) | ||||
|                     .thumbnail(0.3f) | ||||
|                     .into(holder.image) | ||||
|                 notifyItemInserted(position) | ||||
|                 notifyItemRangeChanged(position, itemCount + 1) | ||||
|             } | ||||
|  | @ -248,7 +269,7 @@ class ImageAdapter( | |||
|      */ | ||||
|     private fun onThumbnailClicked( | ||||
|         position: Int, | ||||
|         holder: ImageViewHolder | ||||
|         holder: ImageViewHolder, | ||||
|     ) { | ||||
|         val sharedPreferences: SharedPreferences = | ||||
|             context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) | ||||
|  | @ -269,7 +290,10 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Handle click event on an image, update counter on images. | ||||
|      */ | ||||
|     private fun selectOrRemoveImage(holder: ImageViewHolder, position: Int){ | ||||
|     private fun selectOrRemoveImage( | ||||
|         holder: ImageViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         val sharedPreferences: SharedPreferences = | ||||
|             context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) | ||||
|         val showAlreadyActionedImages = | ||||
|  | @ -277,14 +301,15 @@ class ImageAdapter( | |||
| 
 | ||||
|         // Getting clicked index from all images index when show_already_actioned_images | ||||
|         // switch is on | ||||
|         val clickedIndex: Int = if(showAlreadyActionedImages) { | ||||
|             ImageHelper.getIndex(selectedImages, images[position]) | ||||
|         val clickedIndex: Int = | ||||
|             if (showAlreadyActionedImages) { | ||||
|                 ImageHelper.getIndex(selectedImages, images[position]) | ||||
| 
 | ||||
|         // Getting clicked index from actionable images when show_already_actioned_images | ||||
|         // switch is off | ||||
|         } else { | ||||
|             ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) | ||||
|         } | ||||
|                 // Getting clicked index from actionable images when show_already_actioned_images | ||||
|                 // switch is off | ||||
|             } else { | ||||
|                 ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) | ||||
|             } | ||||
| 
 | ||||
|         if (clickedIndex != -1) { | ||||
|             selectedImages.removeAt(clickedIndex) | ||||
|  | @ -294,13 +319,14 @@ class ImageAdapter( | |||
|             notifyItemChanged(position, ImageUnselected()) | ||||
| 
 | ||||
|             // Getting index from all images index when switch is on | ||||
|             val indexes = if (showAlreadyActionedImages) { | ||||
|                 ImageHelper.getIndexList(selectedImages, images) | ||||
|             val indexes = | ||||
|                 if (showAlreadyActionedImages) { | ||||
|                     ImageHelper.getIndexList(selectedImages, images) | ||||
| 
 | ||||
|             // Getting index from actionable images when switch is off | ||||
|             } else { | ||||
|                 ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) | ||||
|             } | ||||
|                     // Getting index from actionable images when switch is off | ||||
|                 } else { | ||||
|                     ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) | ||||
|                 } | ||||
|             for (index in indexes) { | ||||
|                 notifyItemChanged(index, ImageSelectedOrUpdated()) | ||||
|             } | ||||
|  | @ -313,15 +339,16 @@ class ImageAdapter( | |||
|                 } | ||||
| 
 | ||||
|                 // Getting index from all images index when switch is on | ||||
|                 val indexes: ArrayList<Int> = if (showAlreadyActionedImages) { | ||||
|                     selectedImages.add(images[position]) | ||||
|                     ImageHelper.getIndexList(selectedImages, images) | ||||
|                 val indexes: ArrayList<Int> = | ||||
|                     if (showAlreadyActionedImages) { | ||||
|                         selectedImages.add(images[position]) | ||||
|                         ImageHelper.getIndexList(selectedImages, images) | ||||
| 
 | ||||
|                 // Getting index from actionable images when switch is off | ||||
|                 } else { | ||||
|                     selectedImages.add(ArrayList(actionableImagesMap.values)[position]) | ||||
|                     ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) | ||||
|                 } | ||||
|                         // Getting index from actionable images when switch is off | ||||
|                     } else { | ||||
|                         selectedImages.add(ArrayList(actionableImagesMap.values)[position]) | ||||
|                         ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) | ||||
|                     } | ||||
| 
 | ||||
|                 for (index in indexes) { | ||||
|                     notifyItemChanged(index, ImageSelectedOrUpdated()) | ||||
|  | @ -334,10 +361,15 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Initialize the data set. | ||||
|      */ | ||||
|     fun init(newImages: List<Image>, fixedImages: List<Image>, emptyMap: TreeMap<Int, Image>, uploadedImages: List<Contribution> = ArrayList()) { | ||||
|     fun init( | ||||
|         newImages: List<Image>, | ||||
|         fixedImages: List<Image>, | ||||
|         emptyMap: TreeMap<Int, Image>, | ||||
|         uploadedImages: List<Contribution> = ArrayList(), | ||||
|     ) { | ||||
|         allImages = fixedImages | ||||
|         val oldImageList:ArrayList<Image> = images | ||||
|         val newImageList:ArrayList<Image> = ArrayList(newImages) | ||||
|         val oldImageList: ArrayList<Image> = images | ||||
|         val newImageList: ArrayList<Image> = ArrayList(newImages) | ||||
|         actionableImagesMap = emptyMap | ||||
|         alreadyAddedPositions = ArrayList() | ||||
|         uploadingContributionList = uploadedImages | ||||
|  | @ -345,9 +377,10 @@ class ImageAdapter( | |||
|         reachedEndOfFolder = false | ||||
|         selectedImages = ArrayList() | ||||
|         imagePositionAsPerIncreasingOrder = 0 | ||||
|         val diffResult = DiffUtil.calculateDiff( | ||||
|             ImagesDiffCallback(oldImageList, newImageList) | ||||
|         ) | ||||
|         val diffResult = | ||||
|             DiffUtil.calculateDiff( | ||||
|                 ImagesDiffCallback(oldImageList, newImageList), | ||||
|             ) | ||||
|         images = newImageList | ||||
|         diffResult.dispatchUpdatesTo(this) | ||||
|     } | ||||
|  | @ -355,31 +388,35 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Set new selected images | ||||
|      */ | ||||
|     fun setSelectedImages(newSelectedImages: ArrayList<Image>){ | ||||
|     fun setSelectedImages(newSelectedImages: ArrayList<Image>) { | ||||
|         selectedImages = ArrayList(newSelectedImages) | ||||
|         imageSelectListener.onSelectedImagesChanged(selectedImages, 0) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data in the adapter | ||||
|      */ | ||||
|     fun refresh(newImages: List<Image>, fixedImages: List<Image>, uploadingImages: List<Contribution> = ArrayList()) { | ||||
|     fun refresh( | ||||
|         newImages: List<Image>, | ||||
|         fixedImages: List<Image>, | ||||
|         uploadingImages: List<Contribution> = ArrayList(), | ||||
|     ) { | ||||
|         numberOfSelectedImagesMarkedAsNotForUpload = 0 | ||||
|         images.clear() | ||||
|         selectedImages = arrayListOf() | ||||
|         init(newImages, fixedImages, TreeMap(),uploadingImages) | ||||
|         init(newImages, fixedImages, TreeMap(), uploadingImages) | ||||
|         notifyDataSetChanged() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clear selected images and empty the list. | ||||
|      */ | ||||
|     fun clearSelectedImages(){ | ||||
|     fun clearSelectedImages() { | ||||
|         numberOfSelectedImagesMarkedAsNotForUpload = 0 | ||||
|         selectedImages.clear() | ||||
|         selectedImages = arrayListOf() | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Remove image from actionable images map. | ||||
|      */ | ||||
|  | @ -389,7 +426,7 @@ class ImageAdapter( | |||
|         val showAlreadyActionedImages = | ||||
|             sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) | ||||
| 
 | ||||
|         if(showAlreadyActionedImages) { | ||||
|         if (showAlreadyActionedImages) { | ||||
|             refresh(allImages, allImages, uploadingContributionList) | ||||
|         } else { | ||||
|             val iterator = actionableImagesMap.entries.iterator() | ||||
|  | @ -402,16 +439,14 @@ class ImageAdapter( | |||
|                     iterator.remove() | ||||
|                     alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1) | ||||
|                     notifyItemRemoved(index) | ||||
|                     notifyItemRangeChanged(index, itemCount ) | ||||
|                     notifyItemRangeChanged(index, itemCount) | ||||
|                     break | ||||
|                 } | ||||
|                 index++ | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the total number of items in the data set held by the adapter. | ||||
|      * | ||||
|  | @ -424,24 +459,22 @@ class ImageAdapter( | |||
|             sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) | ||||
| 
 | ||||
|         // While switch is on initializes the holder with all images size | ||||
|         return if(showAlreadyActionedImages) { | ||||
|         return if (showAlreadyActionedImages) { | ||||
|             allImages.size | ||||
| 
 | ||||
|         // While switch is off and searching for next actionable has ended, initializes the holder | ||||
|         // with size of all actionable images | ||||
|             // While switch is off and searching for next actionable has ended, initializes the holder | ||||
|             // with size of all actionable images | ||||
|         } else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) { | ||||
|             actionableImagesMap.size | ||||
| 
 | ||||
|         // While switch is off, initializes the holder with and extra view holder so that finding | ||||
|         // and addition of the next actionable image in the adapter can be continued | ||||
|             // While switch is off, initializes the holder with and extra view holder so that finding | ||||
|             // and addition of the next actionable image in the adapter can be continued | ||||
|         } else { | ||||
|             actionableImagesMap.size + 1 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getImageIdAt(position: Int): Long { | ||||
|         return images.get(position).id | ||||
|     } | ||||
|     fun getImageIdAt(position: Int): Long = images.get(position).id | ||||
| 
 | ||||
|     /** | ||||
|      * CleanUp function. | ||||
|  | @ -453,7 +486,9 @@ class ImageAdapter( | |||
|     /** | ||||
|      * Image view holder. | ||||
|      */ | ||||
|     class ImageViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { | ||||
|     class ImageViewHolder( | ||||
|         itemView: View, | ||||
|     ) : RecyclerView.ViewHolder(itemView) { | ||||
|         val image: ImageView = itemView.findViewById(R.id.image_thumbnail) | ||||
|         private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group) | ||||
|         private val uploadingGroup: Group = itemView.findViewById(R.id.uploading_group) | ||||
|  | @ -495,16 +530,12 @@ class ImageAdapter( | |||
|             notForUploadGroup.visibility = View.VISIBLE | ||||
|         } | ||||
| 
 | ||||
|         fun isItemUploaded():Boolean { | ||||
|             return uploadedGroup.visibility == View.VISIBLE | ||||
|         } | ||||
|         fun isItemUploaded(): Boolean = uploadedGroup.visibility == View.VISIBLE | ||||
| 
 | ||||
|         /** | ||||
|          * Item is not for upload | ||||
|          */ | ||||
|         fun isItemNotForUpload():Boolean { | ||||
|             return notForUploadGroup.visibility == View.VISIBLE | ||||
|         } | ||||
|         fun isItemNotForUpload(): Boolean = notForUploadGroup.visibility == View.VISIBLE | ||||
| 
 | ||||
|         /** | ||||
|          * Item is not uploading | ||||
|  | @ -533,45 +564,38 @@ class ImageAdapter( | |||
|      */ | ||||
|     class ImagesDiffCallback( | ||||
|         var oldImageList: ArrayList<Image>, | ||||
|         var newImageList: ArrayList<Image> | ||||
|     ) : DiffUtil.Callback(){ | ||||
| 
 | ||||
|         var newImageList: ArrayList<Image>, | ||||
|     ) : DiffUtil.Callback() { | ||||
|         /** | ||||
|          * Returns the size of the old list. | ||||
|          */ | ||||
|         override fun getOldListSize(): Int { | ||||
|             return oldImageList.size | ||||
|         } | ||||
|         override fun getOldListSize(): Int = oldImageList.size | ||||
| 
 | ||||
|         /** | ||||
|          * Returns the size of the new list. | ||||
|          */ | ||||
|         override fun getNewListSize(): Int { | ||||
|             return newImageList.size | ||||
|         } | ||||
|         override fun getNewListSize(): Int = newImageList.size | ||||
| 
 | ||||
|         /** | ||||
|          * Called by the DiffUtil to decide whether two object represent the same Item. | ||||
|          */ | ||||
|         override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { | ||||
|             return newImageList[newItemPosition].id == oldImageList[oldItemPosition].id | ||||
|         } | ||||
|         override fun areItemsTheSame( | ||||
|             oldItemPosition: Int, | ||||
|             newItemPosition: Int, | ||||
|         ): Boolean = newImageList[newItemPosition].id == oldImageList[oldItemPosition].id | ||||
| 
 | ||||
|         /** | ||||
|          * Called by the DiffUtil when it wants to check whether two items have the same data. | ||||
|          * DiffUtil uses this information to detect if the contents of an item has changed. | ||||
|          */ | ||||
|         override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { | ||||
|             return oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) | ||||
|         } | ||||
| 
 | ||||
|         override fun areContentsTheSame( | ||||
|             oldItemPosition: Int, | ||||
|             newItemPosition: Int, | ||||
|         ): Boolean = oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the text for showing inside the bubble during bubble scroll. | ||||
|      */ | ||||
|     override fun getSectionName(position: Int): String { | ||||
|         return images[position].date | ||||
|     } | ||||
| 
 | ||||
|     override fun getSectionName(position: Int): String = images[position].date | ||||
| } | ||||
|  |  | |||
|  | @ -7,6 +7,8 @@ import androidx.recyclerview.widget.RecyclerView | |||
| /** | ||||
|  * Generic Recycler view adapter. | ||||
|  */ | ||||
| abstract class RecyclerViewAdapter<T : RecyclerView.ViewHolder?>(val context: Context): RecyclerView.Adapter<T>() { | ||||
| abstract class RecyclerViewAdapter<T : RecyclerView.ViewHolder?>( | ||||
|     val context: Context, | ||||
| ) : RecyclerView.Adapter<T>() { | ||||
|     val inflater: LayoutInflater = LayoutInflater.from(context) | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -8,20 +8,14 @@ import android.content.SharedPreferences | |||
| import android.content.pm.PackageManager | ||||
| import android.os.Build | ||||
| import android.os.Bundle | ||||
| import android.util.Log | ||||
| import android.view.View | ||||
| import android.view.Window | ||||
| import android.widget.Button | ||||
| import android.widget.ImageButton | ||||
| import android.widget.TextView | ||||
| import androidx.compose.foundation.BorderStroke | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.clickable | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material3.Button | ||||
|  | @ -38,7 +32,6 @@ import androidx.compose.runtime.mutableStateOf | |||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.clip | ||||
| import androidx.compose.ui.res.colorResource | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.unit.dp | ||||
|  | @ -57,23 +50,22 @@ import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding | |||
| import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding | ||||
| import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding | ||||
| import fr.free.nrw.commons.filepicker.Constants | ||||
| import fr.free.nrw.commons.filepicker.FilePicker | ||||
| import fr.free.nrw.commons.media.ZoomableActivity | ||||
| import fr.free.nrw.commons.theme.BaseActivity | ||||
| import fr.free.nrw.commons.upload.FileUtilsWrapper | ||||
| import fr.free.nrw.commons.utils.CustomSelectorUtils | ||||
| import fr.free.nrw.commons.utils.PermissionUtils | ||||
| import kotlinx.coroutines.* | ||||
| import java.io.File | ||||
| import java.lang.Integer.max | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Custom Selector Activity. | ||||
|  */ | ||||
| class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectListener { | ||||
| 
 | ||||
| class CustomSelectorActivity : | ||||
|     BaseActivity(), | ||||
|     FolderClickListener, | ||||
|     ImageSelectListener { | ||||
|     /** | ||||
|      * ViewBindings | ||||
|      */ | ||||
|  | @ -147,7 +139,7 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|      */ | ||||
|     var imageFragment: ImageFragment? = null | ||||
| 
 | ||||
|     private var progressDialogText:String="" | ||||
|     private var progressDialogText: String = "" | ||||
| 
 | ||||
|     private var showPartialAccessIndicator by mutableStateOf(false) | ||||
| 
 | ||||
|  | @ -158,7 +150,8 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|         super.onCreate(savedInstanceState) | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && | ||||
|             ContextCompat.checkSelfPermission( | ||||
|                 this, Manifest.permission.READ_MEDIA_IMAGES | ||||
|                 this, | ||||
|                 Manifest.permission.READ_MEDIA_IMAGES, | ||||
|             ) == PackageManager.PERMISSION_DENIED | ||||
|         ) { | ||||
|             showPartialAccessIndicator = true | ||||
|  | @ -171,22 +164,24 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|             PartialStorageAccessIndicator( | ||||
|                 isVisible = showPartialAccessIndicator, | ||||
|                 onManage = { | ||||
|                     if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { | ||||
|                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { | ||||
|                         requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) | ||||
|                     } | ||||
|                 }, | ||||
|                 modifier = Modifier | ||||
|                     .padding(vertical = 8.dp, horizontal = 4.dp) | ||||
|                     .fillMaxWidth() | ||||
|                 modifier = | ||||
|                     Modifier | ||||
|                         .padding(vertical = 8.dp, horizontal = 4.dp) | ||||
|                         .fillMaxWidth(), | ||||
|             ) | ||||
|         } | ||||
|         val view = binding.root | ||||
|         setContentView(view) | ||||
| 
 | ||||
|         prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) | ||||
|         viewModel = ViewModelProvider(this, customSelectorViewModelFactory).get( | ||||
|             CustomSelectorViewModel::class.java | ||||
|         ) | ||||
|         viewModel = | ||||
|             ViewModelProvider(this, customSelectorViewModelFactory).get( | ||||
|                 CustomSelectorViewModel::class.java, | ||||
|             ) | ||||
| 
 | ||||
|         setupViews() | ||||
| 
 | ||||
|  | @ -208,11 +203,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|     override fun onRequestPermissionsResult( | ||||
|         requestCode: Int, | ||||
|         permissions: Array<out String>, | ||||
|         grantResults: IntArray | ||||
|         grantResults: IntArray, | ||||
|     ) { | ||||
|         super.onRequestPermissionsResult(requestCode, permissions, grantResults) | ||||
|         if(requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { | ||||
|             if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { | ||||
|         if (requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { | ||||
|             if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { | ||||
|                 showPartialAccessIndicator = false | ||||
|             } | ||||
|         } | ||||
|  | @ -226,7 +221,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|     /** | ||||
|      * When data will be send from full screen mode, it will be passed to fragment | ||||
|      */ | ||||
|     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { | ||||
|     override fun onActivityResult( | ||||
|         requestCode: Int, | ||||
|         resultCode: Int, | ||||
|         data: Intent?, | ||||
|     ) { | ||||
|         super.onActivityResult(requestCode, resultCode, data) | ||||
|         if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE && | ||||
|             resultCode == Activity.RESULT_OK | ||||
|  | @ -254,7 +253,8 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|      * Set up view, default folder view. | ||||
|      */ | ||||
|     private fun setupViews() { | ||||
|         supportFragmentManager.beginTransaction() | ||||
|         supportFragmentManager | ||||
|             .beginTransaction() | ||||
|             .replace(R.id.fragment_container, FolderFragment.newInstance()) | ||||
|             .commit() | ||||
|         setUpToolbar() | ||||
|  | @ -322,12 +322,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
| 
 | ||||
|             var allImagesAlreadyNotForUpload = true | ||||
|             images.forEach { image -> | ||||
|                 val imageSHA1 = CustomSelectorUtils.getImageSHA1( | ||||
|                     image.uri, | ||||
|                     ioDispatcher, | ||||
|                     fileUtilsWrapper, | ||||
|                     contentResolver | ||||
|                 ) | ||||
|                 val imageSHA1 = | ||||
|                     CustomSelectorUtils.getImageSHA1( | ||||
|                         image.uri, | ||||
|                         ioDispatcher, | ||||
|                         fileUtilsWrapper, | ||||
|                         contentResolver, | ||||
|                     ) | ||||
|                 val exists = notForUploadStatusDao.find(imageSHA1) | ||||
|                 if (exists < 1) { | ||||
|                     allImagesAlreadyNotForUpload = false | ||||
|  | @ -337,12 +338,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|             if (!allImagesAlreadyNotForUpload) { | ||||
|                 // Insert or delete images as necessary, but the UI updates should be posted back to the main thread | ||||
|                 images.forEach { image -> | ||||
|                     val imageSHA1 = CustomSelectorUtils.getImageSHA1( | ||||
|                         image.uri, | ||||
|                         ioDispatcher, | ||||
|                         fileUtilsWrapper, | ||||
|                         contentResolver | ||||
|                     ) | ||||
|                     val imageSHA1 = | ||||
|                         CustomSelectorUtils.getImageSHA1( | ||||
|                             image.uri, | ||||
|                             ioDispatcher, | ||||
|                             fileUtilsWrapper, | ||||
|                             contentResolver, | ||||
|                         ) | ||||
|                     notForUploadStatusDao.insert(NotForUploadStatus(imageSHA1)) | ||||
|                 } | ||||
|                 withContext(Dispatchers.Main) { | ||||
|  | @ -353,12 +355,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|                 } | ||||
|             } else { | ||||
|                 images.forEach { image -> | ||||
|                     val imageSHA1 = CustomSelectorUtils.getImageSHA1( | ||||
|                         image.uri, | ||||
|                         ioDispatcher, | ||||
|                         fileUtilsWrapper, | ||||
|                         contentResolver | ||||
|                     ) | ||||
|                     val imageSHA1 = | ||||
|                         CustomSelectorUtils.getImageSHA1( | ||||
|                             image.uri, | ||||
|                             ioDispatcher, | ||||
|                             fileUtilsWrapper, | ||||
|                             contentResolver, | ||||
|                         ) | ||||
|                     notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1) | ||||
|                 } | ||||
| 
 | ||||
|  | @ -386,13 +389,19 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|     /** | ||||
|      * Change the title of the toolbar. | ||||
|      */ | ||||
|     private fun changeTitle(title: String, selectedImageCount:Int) { | ||||
|         if (title.isNotEmpty()){ | ||||
|     private fun changeTitle( | ||||
|         title: String, | ||||
|         selectedImageCount: Int, | ||||
|     ) { | ||||
|         if (title.isNotEmpty()) { | ||||
|             val titleText = findViewById<TextView>(R.id.title) | ||||
|             var titleWithAppendedImageCount = title | ||||
|             if (selectedImageCount > 0) { | ||||
|                 titleWithAppendedImageCount += " (${resources.getQuantityString(R.plurals.custom_picker_images_selected_title_appendix,  | ||||
|                     selectedImageCount, selectedImageCount)})" | ||||
|                 titleWithAppendedImageCount += " (${resources.getQuantityString( | ||||
|                     R.plurals.custom_picker_images_selected_title_appendix, | ||||
|                     selectedImageCount, | ||||
|                     selectedImageCount, | ||||
|                 )})" | ||||
|             } | ||||
|             if (titleText != null) { | ||||
|                 titleText.text = titleWithAppendedImageCount | ||||
|  | @ -415,8 +424,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|     /** | ||||
|      * override on folder click, change the toolbar title on folder click. | ||||
|      */ | ||||
|     override fun onFolderClick(folderId: Long, folderName: String, lastItemId: Long) { | ||||
|         supportFragmentManager.beginTransaction() | ||||
|     override fun onFolderClick( | ||||
|         folderId: Long, | ||||
|         folderName: String, | ||||
|         lastItemId: Long, | ||||
|     ) { | ||||
|         supportFragmentManager | ||||
|             .beginTransaction() | ||||
|             .add(R.id.fragment_container, ImageFragment.newInstance(folderId, lastItemId)) | ||||
|             .addToBackStack(null) | ||||
|             .commit() | ||||
|  | @ -433,18 +447,21 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|      */ | ||||
|     override fun onSelectedImagesChanged( | ||||
|         selectedImages: ArrayList<Image>, | ||||
|         selectedNotForUploadImages: Int | ||||
|         selectedNotForUploadImages: Int, | ||||
|     ) { | ||||
|         viewModel.selectedImages.value = selectedImages | ||||
|         changeTitle(bucketName, selectedImages.size) | ||||
| 
 | ||||
|         uploadLimitExceeded = selectedImages.size > uploadLimit | ||||
|         uploadLimitExceededBy = max(selectedImages.size - uploadLimit,0) | ||||
|         uploadLimitExceededBy = max(selectedImages.size - uploadLimit, 0) | ||||
| 
 | ||||
|         if (uploadLimitExceeded && selectedNotForUploadImages == 0) { | ||||
|             toolbarBinding.imageLimitError.visibility = View.VISIBLE | ||||
|             bottomSheetBinding.upload.text = resources.getString( | ||||
|                 R.string.custom_selector_button_limit_text, uploadLimit) | ||||
|             bottomSheetBinding.upload.text = | ||||
|                 resources.getString( | ||||
|                     R.string.custom_selector_button_limit_text, | ||||
|                     uploadLimit, | ||||
|                 ) | ||||
|         } else { | ||||
|             toolbarBinding.imageLimitError.visibility = View.INVISIBLE | ||||
|             bottomSheetBinding.upload.text = resources.getString(R.string.upload) | ||||
|  | @ -461,11 +478,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|         bottomSheetBinding.notForUpload.text = | ||||
|             when (selectedImages.size == selectedNotForUploadImages) { | ||||
|                 true -> { | ||||
|                     progressDialogText=getString(R.string.unmarking_as_not_for_upload) | ||||
|                     progressDialogText = getString(R.string.unmarking_as_not_for_upload) | ||||
|                     getString(R.string.unmark_as_not_for_upload) | ||||
|                 } | ||||
|                 else -> { | ||||
|                     progressDialogText=getString(R.string.marking_as_not_for_upload) | ||||
|                     progressDialogText = getString(R.string.marking_as_not_for_upload) | ||||
|                     getString(R.string.mark_as_not_for_upload) | ||||
|                 } | ||||
|             } | ||||
|  | @ -481,13 +498,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|     override fun onLongPress( | ||||
|         position: Int, | ||||
|         images: ArrayList<Image>, | ||||
|         selectedImages: ArrayList<Image> | ||||
|         selectedImages: ArrayList<Image>, | ||||
|     ) { | ||||
|         val intent = Intent(this, ZoomableActivity::class.java) | ||||
|         intent.putExtra(CustomSelectorConstants.PRESENT_POSITION, position) | ||||
|         intent.putParcelableArrayListExtra( | ||||
|             CustomSelectorConstants.TOTAL_SELECTED_IMAGES, | ||||
|             selectedImages | ||||
|             selectedImages, | ||||
|         ) | ||||
|         intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId) | ||||
|         startActivityForResult(intent, Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE) | ||||
|  | @ -498,22 +515,22 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|      * Get the selected images. Remove any non existent file, forward the data to finish selector. | ||||
|      */ | ||||
|     fun onDone() { | ||||
|             val selectedImages = viewModel.selectedImages.value | ||||
|             if (selectedImages.isNullOrEmpty()) { | ||||
|                 finishPickImages(arrayListOf()) | ||||
|                 return | ||||
|         val selectedImages = viewModel.selectedImages.value | ||||
|         if (selectedImages.isNullOrEmpty()) { | ||||
|             finishPickImages(arrayListOf()) | ||||
|             return | ||||
|         } | ||||
|         var i = 0 | ||||
|         while (i < selectedImages.size) { | ||||
|             val path = selectedImages[i].path | ||||
|             val file = File(path) | ||||
|             if (!file.exists()) { | ||||
|                 selectedImages.removeAt(i) | ||||
|                 i-- | ||||
|             } | ||||
|             var i = 0 | ||||
|             while (i < selectedImages.size) { | ||||
|                 val path = selectedImages[i].path | ||||
|                 val file = File(path) | ||||
|                 if (!file.exists()) { | ||||
|                     selectedImages.removeAt(i) | ||||
|                     i-- | ||||
|                 } | ||||
|                 i++ | ||||
|             } | ||||
|             finishPickImages(selectedImages) | ||||
|             i++ | ||||
|         } | ||||
|         finishPickImages(selectedImages) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -547,10 +564,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|         val dialog = Dialog(this) | ||||
|         dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) | ||||
|         dialog.setContentView(R.layout.custom_selector_limit_dialog) | ||||
|         (dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener() | ||||
|         { dialog.dismiss() } | ||||
|         (dialog.findViewById(R.id.upload_limit_warning) as TextView).text = resources.getString( | ||||
|             R.string.custom_selector_over_limit_warning, uploadLimit, uploadLimitExceededBy) | ||||
|         (dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener { dialog.dismiss() } | ||||
|         (dialog.findViewById(R.id.upload_limit_warning) as TextView).text = | ||||
|             resources.getString( | ||||
|                 R.string.custom_selector_over_limit_warning, | ||||
|                 uploadLimit, | ||||
|                 uploadLimitExceededBy, | ||||
|             ) | ||||
|         dialog.show() | ||||
|     } | ||||
| 
 | ||||
|  | @ -560,9 +580,17 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|      */ | ||||
|     override fun onDestroy() { | ||||
|         if (isImageFragmentOpen) { | ||||
|             prefs.edit().putLong(FOLDER_ID, bucketId).putString(FOLDER_NAME, bucketName).apply() | ||||
|             prefs | ||||
|                 .edit() | ||||
|                 .putLong(FOLDER_ID, bucketId) | ||||
|                 .putString(FOLDER_NAME, bucketName) | ||||
|                 .apply() | ||||
|         } else { | ||||
|             prefs.edit().remove(FOLDER_ID).remove(FOLDER_NAME).apply() | ||||
|             prefs | ||||
|                 .edit() | ||||
|                 .remove(FOLDER_ID) | ||||
|                 .remove(FOLDER_NAME) | ||||
|                 .apply() | ||||
|         } | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | @ -573,38 +601,41 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL | |||
|         const val ITEM_ID: String = "ItemId" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Composable | ||||
| fun PartialStorageAccessIndicator( | ||||
|     isVisible: Boolean, | ||||
|     onManage: ()-> Unit, | ||||
|     modifier: Modifier = Modifier | ||||
|     onManage: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     if(isVisible) { | ||||
|     if (isVisible) { | ||||
|         OutlinedCard( | ||||
|             modifier = modifier, | ||||
|             colors = CardDefaults.cardColors( | ||||
|                 containerColor = colorResource(R.color.primarySuperLightColor) | ||||
|             ), | ||||
|             colors = | ||||
|                 CardDefaults.cardColors( | ||||
|                     containerColor = colorResource(R.color.primarySuperLightColor), | ||||
|                 ), | ||||
|             border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)), | ||||
|             shape = RoundedCornerShape(8.dp) | ||||
|             shape = RoundedCornerShape(8.dp), | ||||
|         ) { | ||||
|             Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) { | ||||
|                 Text( | ||||
|                     text = "You've given access to a select number of photos", | ||||
|                     modifier = Modifier.weight(1f) | ||||
|                     modifier = Modifier.weight(1f), | ||||
|                 ) | ||||
|                 TextButton( | ||||
|                     onClick = onManage, | ||||
|                     modifier = Modifier.align(Alignment.Bottom), | ||||
|                     colors = ButtonDefaults.buttonColors( | ||||
|                         containerColor = colorResource(R.color.primaryColor) | ||||
|                     ), | ||||
|                     shape = RoundedCornerShape(8.dp) | ||||
|                     colors = | ||||
|                         ButtonDefaults.buttonColors( | ||||
|                             containerColor = colorResource(R.color.primaryColor), | ||||
|                         ), | ||||
|                     shape = RoundedCornerShape(8.dp), | ||||
|                 ) { | ||||
|                     Text( | ||||
|                         text = "Manage", | ||||
|                         style = MaterialTheme.typography.labelMedium, | ||||
|                         color = colorResource(R.color.primaryTextColor) | ||||
|                         color = colorResource(R.color.primaryTextColor), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|  | @ -616,9 +647,13 @@ fun PartialStorageAccessIndicator( | |||
| @Composable | ||||
| fun PartialStorageAccessIndicatorPreview() { | ||||
|     Surface { | ||||
|         PartialStorageAccessIndicator(isVisible = true, onManage = {}, modifier = Modifier | ||||
|             .padding(vertical = 8.dp, horizontal = 4.dp) | ||||
|             .fillMaxWidth() | ||||
|         PartialStorageAccessIndicator( | ||||
|             isVisible = true, | ||||
|             onManage = {}, | ||||
|             modifier = | ||||
|                 Modifier | ||||
|                     .padding(vertical = 8.dp, horizontal = 4.dp) | ||||
|                     .fillMaxWidth(), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -14,8 +14,10 @@ import kotlinx.coroutines.cancel | |||
| /** | ||||
|  * Custom Selector view model. | ||||
|  */ | ||||
| class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFileLoader) : ViewModel() { | ||||
| 
 | ||||
| class CustomSelectorViewModel( | ||||
|     var context: Context, | ||||
|     var imageFileLoader: ImageFileLoader, | ||||
| ) : ViewModel() { | ||||
|     /** | ||||
|      * Scope for coroutine task (image fetch). | ||||
|      */ | ||||
|  | @ -37,15 +39,17 @@ class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFil | |||
|     fun fetchImages() { | ||||
|         result.postValue(Result(CallbackStatus.FETCHING, arrayListOf())) | ||||
|         scope.cancel() | ||||
|         imageFileLoader.loadDeviceImages(object: ImageLoaderListener { | ||||
|             override fun onImageLoaded(images: ArrayList<Image>) { | ||||
|                 result.postValue(Result(CallbackStatus.SUCCESS, images)) | ||||
|             } | ||||
|         imageFileLoader.loadDeviceImages( | ||||
|             object : ImageLoaderListener { | ||||
|                 override fun onImageLoaded(images: ArrayList<Image>) { | ||||
|                     result.postValue(Result(CallbackStatus.SUCCESS, images)) | ||||
|                 } | ||||
| 
 | ||||
|             override fun onFailed(throwable: Throwable) { | ||||
|                 result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf())) | ||||
|             } | ||||
|         }) | ||||
|                 override fun onFailed(throwable: Throwable) { | ||||
|                     result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf())) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -55,4 +59,4 @@ class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFil | |||
|         scope.cancel() | ||||
|         super.onCleared() | ||||
|     } | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -8,10 +8,12 @@ import javax.inject.Inject | |||
| /** | ||||
|  * View Model Factory. | ||||
|  */ | ||||
| class CustomSelectorViewModelFactory @Inject constructor(val context: Context,val imageFileLoader: ImageFileLoader) : ViewModelProvider.Factory { | ||||
| 
 | ||||
|     override fun<CustomSelectorViewModel: ViewModel> create(modelClass: Class<CustomSelectorViewModel>) : CustomSelectorViewModel { | ||||
|         return CustomSelectorViewModel(context,imageFileLoader) as CustomSelectorViewModel | ||||
| class CustomSelectorViewModelFactory | ||||
|     @Inject | ||||
|     constructor( | ||||
|         val context: Context, | ||||
|         val imageFileLoader: ImageFileLoader, | ||||
|     ) : ViewModelProvider.Factory { | ||||
|         override fun <CustomSelectorViewModel : ViewModel> create(modelClass: Class<CustomSelectorViewModel>): CustomSelectorViewModel = | ||||
|             CustomSelectorViewModel(context, imageFileLoader) as CustomSelectorViewModel | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -9,10 +9,10 @@ import androidx.lifecycle.ViewModelProvider | |||
| import androidx.recyclerview.widget.GridLayoutManager | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import fr.free.nrw.commons.customselector.helper.ImageHelper | ||||
| import fr.free.nrw.commons.customselector.model.Result | ||||
| import fr.free.nrw.commons.customselector.listeners.FolderClickListener | ||||
| import fr.free.nrw.commons.customselector.model.CallbackStatus | ||||
| import fr.free.nrw.commons.customselector.model.Folder | ||||
| import fr.free.nrw.commons.customselector.model.Result | ||||
| import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter | ||||
| import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding | ||||
| import fr.free.nrw.commons.di.CommonsDaggerSupportFragment | ||||
|  | @ -24,7 +24,6 @@ import javax.inject.Inject | |||
|  * Custom selector folder fragment. | ||||
|  */ | ||||
| class FolderFragment : CommonsDaggerSupportFragment() { | ||||
| 
 | ||||
|     /** | ||||
|      * ViewBinding | ||||
|      */ | ||||
|  | @ -53,6 +52,7 @@ class FolderFragment : CommonsDaggerSupportFragment() { | |||
| 
 | ||||
|     var mediaClient: MediaClient? = null | ||||
|         @Inject set | ||||
| 
 | ||||
|     /** | ||||
|      * Folder Adapter. | ||||
|      */ | ||||
|  | @ -66,15 +66,13 @@ class FolderFragment : CommonsDaggerSupportFragment() { | |||
|     /** | ||||
|      * Folder List. | ||||
|      */ | ||||
|     private lateinit var folders : ArrayList<Folder> | ||||
|     private lateinit var folders: ArrayList<Folder> | ||||
| 
 | ||||
|     /** | ||||
|      * Companion newInstance. | ||||
|      */ | ||||
|     companion object{ | ||||
|         fun newInstance(): FolderFragment { | ||||
|             return FolderFragment() | ||||
|         } | ||||
|     companion object { | ||||
|         fun newInstance(): FolderFragment = FolderFragment() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -83,21 +81,24 @@ class FolderFragment : CommonsDaggerSupportFragment() { | |||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
| 
 | ||||
|         viewModel = ViewModelProvider(requireActivity(),customSelectorViewModelFactory!!).get(CustomSelectorViewModel::class.java) | ||||
| 
 | ||||
|         viewModel = ViewModelProvider(requireActivity(), customSelectorViewModelFactory!!).get(CustomSelectorViewModel::class.java) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * OnCreateView. | ||||
|      * Inflate Layout, init adapter, init gridLayoutManager, setUp recycler view, observe the view model for result. | ||||
|      */ | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ): View? { | ||||
|         _binding = FragmentCustomSelectorBinding.inflate(inflater, container, false) | ||||
|         folderAdapter = FolderAdapter(requireActivity(), activity as FolderClickListener) | ||||
|         gridLayoutManager = GridLayoutManager(context, columnCount()) | ||||
|         selectorRV = binding?.selectorRv | ||||
|         loader = binding?.loader | ||||
|         with(binding?.selectorRv){ | ||||
|         with(binding?.selectorRv) { | ||||
|             this?.layoutManager = gridLayoutManager | ||||
|             this?.setHasFixedSize(true) | ||||
|             this?.adapter = folderAdapter | ||||
|  | @ -114,9 +115,9 @@ class FolderFragment : CommonsDaggerSupportFragment() { | |||
|      * Load adapter. | ||||
|      */ | ||||
|     private fun handleResult(result: Result) { | ||||
|         if(result.status is CallbackStatus.SUCCESS){ | ||||
|         if (result.status is CallbackStatus.SUCCESS) { | ||||
|             val images = result.images | ||||
|             if(images.isEmpty()){ | ||||
|             if (images.isEmpty()) { | ||||
|                 binding?.emptyText?.let { | ||||
|                     it.visibility = View.VISIBLE | ||||
|                 } | ||||
|  |  | |||
|  | @ -20,8 +20,9 @@ import kotlin.coroutines.CoroutineContext | |||
|  * Custom Selector Image File Loader. | ||||
|  * Loads device images. | ||||
|  */ | ||||
| class ImageFileLoader(val context: Context) : CoroutineScope{ | ||||
| 
 | ||||
| class ImageFileLoader( | ||||
|     val context: Context, | ||||
| ) : CoroutineScope { | ||||
|     /** | ||||
|      * Coroutine context for fetching images. | ||||
|      */ | ||||
|  | @ -30,14 +31,15 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ | |||
|     /** | ||||
|      * Media paramerters required. | ||||
|      */ | ||||
|     private val projection = arrayOf( | ||||
|         MediaStore.Images.Media._ID, | ||||
|         MediaStore.Images.Media.DISPLAY_NAME, | ||||
|         MediaStore.Images.Media.DATA, | ||||
|         MediaStore.Images.Media.BUCKET_ID, | ||||
|         MediaStore.Images.Media.BUCKET_DISPLAY_NAME, | ||||
|         MediaStore.Images.Media.DATE_ADDED | ||||
|     ) | ||||
|     private val projection = | ||||
|         arrayOf( | ||||
|             MediaStore.Images.Media._ID, | ||||
|             MediaStore.Images.Media.DISPLAY_NAME, | ||||
|             MediaStore.Images.Media.DATA, | ||||
|             MediaStore.Images.Media.BUCKET_ID, | ||||
|             MediaStore.Images.Media.BUCKET_DISPLAY_NAME, | ||||
|             MediaStore.Images.Media.DATE_ADDED, | ||||
|         ) | ||||
| 
 | ||||
|     /** | ||||
|      * Load Device Images under coroutine. | ||||
|  | @ -50,12 +52,18 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ | |||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Load Device images using cursor | ||||
|      */ | ||||
|     private fun getImages(listener:ImageLoaderListener) { | ||||
|         val cursor = context.contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, MediaStore.Images.Media.DATE_ADDED + " DESC") | ||||
|     private fun getImages(listener: ImageLoaderListener) { | ||||
|         val cursor = | ||||
|             context.contentResolver.query( | ||||
|                 MediaStore.Images.Media.EXTERNAL_CONTENT_URI, | ||||
|                 projection, | ||||
|                 null, | ||||
|                 null, | ||||
|                 MediaStore.Images.Media.DATE_ADDED + " DESC", | ||||
|             ) | ||||
|         if (cursor == null) { | ||||
|             listener.onFailed(NullPointerException()) | ||||
|             return | ||||
|  | @ -85,10 +93,12 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ | |||
|                 val file = | ||||
|                     if (path == null || path.isEmpty()) { | ||||
|                         null | ||||
|                     } else try { | ||||
|                         File(path) | ||||
|                     } catch (ignored: Exception) { | ||||
|                         null | ||||
|                     } else { | ||||
|                         try { | ||||
|                             File(path) | ||||
|                         } catch (ignored: Exception) { | ||||
|                             null | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                 if (file != null && file.exists() && name != null && path != null && bucketName != null) { | ||||
|  | @ -106,30 +116,29 @@ class ImageFileLoader(val context: Context) : CoroutineScope{ | |||
|                     val dateFormat = DateFormat.getMediumDateFormat(context) | ||||
|                     val formattedDate = dateFormat.format(date) | ||||
| 
 | ||||
|                     val image = Image( | ||||
|                         id, | ||||
|                         name, | ||||
|                         uri, | ||||
|                         path, | ||||
|                         bucketId, | ||||
|                         bucketName, | ||||
|                         date = (formattedDate) | ||||
|                     ) | ||||
|                     val image = | ||||
|                         Image( | ||||
|                             id, | ||||
|                             name, | ||||
|                             uri, | ||||
|                             path, | ||||
|                             bucketId, | ||||
|                             bucketName, | ||||
|                             date = (formattedDate), | ||||
|                         ) | ||||
|                     images.add(image) | ||||
|                 } | ||||
| 
 | ||||
|             } while (cursor.moveToNext()) | ||||
|         } | ||||
|         cursor.close() | ||||
|         listener.onImageLoaded(images) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Abort loading images. | ||||
|      */ | ||||
|     fun abortLoadImage(){ | ||||
|         //todo Abort loading images. | ||||
|     fun abortLoadImage() { | ||||
|         // todo Abort loading images. | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|  |  | |||
|  | @ -44,8 +44,10 @@ import kotlin.collections.ArrayList | |||
| /** | ||||
|  * Custom Selector Image Fragment. | ||||
|  */ | ||||
| class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDataListener { | ||||
| 
 | ||||
| class ImageFragment : | ||||
|     CommonsDaggerSupportFragment(), | ||||
|     RefreshUIListener, | ||||
|     PassDataListener { | ||||
|     private var _binding: FragmentCustomSelectorBinding? = null | ||||
|     private val binding get() = _binding | ||||
| 
 | ||||
|  | @ -107,7 +109,6 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     private lateinit var progressDialog: AlertDialog | ||||
|     private lateinit var progressDialogLayout: ProgressDialogBinding | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * NotForUploadStatus Dao class for database operations | ||||
|      */ | ||||
|  | @ -142,7 +143,6 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     lateinit var contributionDao: ContributionDao | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         /** | ||||
|          * Switch state | ||||
|          */ | ||||
|  | @ -157,7 +157,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|         /** | ||||
|          * newInstance from bucketId. | ||||
|          */ | ||||
|         fun newInstance(bucketId: Long, lastItemId: Long): ImageFragment { | ||||
|         fun newInstance( | ||||
|             bucketId: Long, | ||||
|             lastItemId: Long, | ||||
|         ): ImageFragment { | ||||
|             val fragment = ImageFragment() | ||||
|             val args = Bundle() | ||||
|             args.putLong(BUCKET_ID, bucketId) | ||||
|  | @ -175,9 +178,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|         super.onCreate(savedInstanceState) | ||||
|         bucketId = arguments?.getLong(BUCKET_ID) | ||||
|         lastItemId = arguments?.getLong(LAST_ITEM_ID, 0) | ||||
|         viewModel = ViewModelProvider(requireActivity(), customSelectorViewModelFactory).get( | ||||
|             CustomSelectorViewModel::class.java | ||||
|         ) | ||||
|         viewModel = | ||||
|             ViewModelProvider(requireActivity(), customSelectorViewModelFactory).get( | ||||
|                 CustomSelectorViewModel::class.java, | ||||
|             ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -188,7 +192,7 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|         savedInstanceState: Bundle?, | ||||
|     ): View? { | ||||
|         _binding = FragmentCustomSelectorBinding.inflate(inflater, container, false) | ||||
|         imageAdapter = | ||||
|  | @ -200,9 +204,12 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|             this?.adapter = imageAdapter | ||||
|         } | ||||
| 
 | ||||
|         viewModel?.result?.observe(viewLifecycleOwner, Observer { | ||||
|             handleResult(it) | ||||
|         }) | ||||
|         viewModel?.result?.observe( | ||||
|             viewLifecycleOwner, | ||||
|             Observer { | ||||
|                 handleResult(it) | ||||
|             }, | ||||
|         ) | ||||
| 
 | ||||
|         switch = binding?.switchWidget | ||||
|         switch?.visibility = View.VISIBLE | ||||
|  | @ -323,20 +330,22 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     override fun onDestroy() { | ||||
|         imageAdapter.cleanUp() | ||||
| 
 | ||||
|         val position = (selectorRV?.layoutManager as GridLayoutManager) | ||||
|             .findFirstVisibleItemPosition() | ||||
|         val position = | ||||
|             (selectorRV?.layoutManager as GridLayoutManager) | ||||
|                 .findFirstVisibleItemPosition() | ||||
| 
 | ||||
|         // Check for empty RecyclerView. | ||||
|         if (position != -1 && filteredImages.size > 0) { | ||||
|             context?.let { context -> | ||||
|                 context.getSharedPreferences( | ||||
|                     "CustomSelector", | ||||
|                     BaseActivity.MODE_PRIVATE | ||||
|                 )?.let { prefs -> | ||||
|                     prefs.edit()?.let { editor -> | ||||
|                         editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply() | ||||
|                 context | ||||
|                     .getSharedPreferences( | ||||
|                         "CustomSelector", | ||||
|                         BaseActivity.MODE_PRIVATE, | ||||
|                     )?.let { prefs -> | ||||
|                         prefs.edit()?.let { editor -> | ||||
|                             editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         super.onDestroy() | ||||
|  | @ -354,7 +363,7 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     /** | ||||
|      * Removes the image from the actionable image map | ||||
|      */ | ||||
|     fun removeImage(image : Image){ | ||||
|     fun removeImage(image: Image) { | ||||
|         imageAdapter.removeImageFromActionableImageMap(image) | ||||
|     } | ||||
| 
 | ||||
|  | @ -364,11 +373,15 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|     fun clearSelectedImages() { | ||||
|         imageAdapter.clearSelectedImages() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Passes selected images and other information from Activity to Fragment and connects it with | ||||
|      * the adapter | ||||
|      */ | ||||
|     override fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean) { | ||||
|     override fun passSelectedImages( | ||||
|         selectedImages: ArrayList<Image>, | ||||
|         shouldRefresh: Boolean, | ||||
|     ) { | ||||
|         imageAdapter.setSelectedImages(selectedImages) | ||||
| 
 | ||||
|         val uploadingContributions = getUploadingContributions() | ||||
|  | @ -398,11 +411,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun getUploadingContributions(): List<Contribution> { | ||||
| 
 | ||||
|         return  contributionDao.getContribution( | ||||
|             listOf(Contribution.STATE_IN_PROGRESS, Contribution.STATE_FAILED, Contribution.STATE_QUEUED, Contribution.STATE_PAUSED) | ||||
|         )?.subscribeOn(Schedulers.io())?.blockingGet() ?: emptyList() | ||||
|     } | ||||
| 
 | ||||
|     private fun getUploadingContributions(): List<Contribution> = | ||||
|         contributionDao | ||||
|             .getContribution( | ||||
|                 listOf(Contribution.STATE_IN_PROGRESS, Contribution.STATE_FAILED, Contribution.STATE_QUEUED, Contribution.STATE_PAUSED), | ||||
|             )?.subscribeOn(Schedulers.io()) | ||||
|             ?.blockingGet() ?: emptyList() | ||||
| } | ||||
|  |  | |||
|  | @ -23,360 +23,378 @@ import javax.inject.Inject | |||
| /** | ||||
|  * Image Loader class, loads images, depending on API results. | ||||
|  */ | ||||
| class ImageLoader @Inject constructor( | ||||
| 
 | ||||
|     /** | ||||
|      * MediaClient for SHA1 query. | ||||
|      */ | ||||
|     var mediaClient: MediaClient, | ||||
| 
 | ||||
|     /** | ||||
|      * FileProcessor to pre-process the file. | ||||
|      */ | ||||
|     var fileProcessor: FileProcessor, | ||||
| 
 | ||||
|     /** | ||||
|      * File Utils Wrapper for SHA1 | ||||
|      */ | ||||
|     var fileUtilsWrapper: FileUtilsWrapper, | ||||
| 
 | ||||
|     /** | ||||
|      * UploadedStatusDao for cache query. | ||||
|      */ | ||||
|     var uploadedStatusDao: UploadedStatusDao, | ||||
| 
 | ||||
|     /** | ||||
|      * NotForUploadDao for database operations | ||||
|      */ | ||||
|     var notForUploadStatusDao: NotForUploadStatusDao, | ||||
| 
 | ||||
|     /** | ||||
|      * Context for coroutine. | ||||
|      */ | ||||
|     val context: Context | ||||
| ) { | ||||
| 
 | ||||
|     /** | ||||
|      * Maps to facilitate image query. | ||||
|      */ | ||||
|     private var mapModifiedImageSHA1: HashMap<Image, String> = HashMap() | ||||
|     private var mapHolderImage : HashMap<ImageViewHolder, Image> = HashMap() | ||||
|     private var mapResult: HashMap<String, Result> = HashMap() | ||||
|     private var mapImageSHA1: HashMap<Uri, String> = HashMap() | ||||
| 
 | ||||
|     /** | ||||
|      * Coroutine Scope. | ||||
|      */ | ||||
|     private val scope : CoroutineScope = MainScope() | ||||
| 
 | ||||
|     /** | ||||
|      * Query image and setUp the view. | ||||
|      */ | ||||
|     fun queryAndSetView( | ||||
|         holder: ImageViewHolder, | ||||
|         image: Image, | ||||
|         ioDispatcher: CoroutineDispatcher, | ||||
|         defaultDispatcher: CoroutineDispatcher, | ||||
|         uploadedContributionsList : List<Contribution> | ||||
| class ImageLoader | ||||
|     @Inject | ||||
|     constructor( | ||||
|         /** | ||||
|          * MediaClient for SHA1 query. | ||||
|          */ | ||||
|         var mediaClient: MediaClient, | ||||
|         /** | ||||
|          * FileProcessor to pre-process the file. | ||||
|          */ | ||||
|         var fileProcessor: FileProcessor, | ||||
|         /** | ||||
|          * File Utils Wrapper for SHA1 | ||||
|          */ | ||||
|         var fileUtilsWrapper: FileUtilsWrapper, | ||||
|         /** | ||||
|          * UploadedStatusDao for cache query. | ||||
|          */ | ||||
|         var uploadedStatusDao: UploadedStatusDao, | ||||
|         /** | ||||
|          * NotForUploadDao for database operations | ||||
|          */ | ||||
|         var notForUploadStatusDao: NotForUploadStatusDao, | ||||
|         /** | ||||
|          * Context for coroutine. | ||||
|          */ | ||||
|         val context: Context, | ||||
|     ) { | ||||
|         /** | ||||
|          * Maps to facilitate image query. | ||||
|          */ | ||||
|         private var mapModifiedImageSHA1: HashMap<Image, String> = HashMap() | ||||
|         private var mapHolderImage: HashMap<ImageViewHolder, Image> = HashMap() | ||||
|         private var mapResult: HashMap<String, Result> = HashMap() | ||||
|         private var mapImageSHA1: HashMap<Uri, String> = HashMap() | ||||
| 
 | ||||
|         /** | ||||
|          * Recycler view uses same view holder, so we can identify the latest query image from holder. | ||||
|          * Coroutine Scope. | ||||
|          */ | ||||
|         mapHolderImage[holder] = image | ||||
|         holder.itemNotUploaded() | ||||
|         holder.itemForUpload() | ||||
|         holder.itemNotUploading() | ||||
|         private val scope: CoroutineScope = MainScope() | ||||
| 
 | ||||
|         scope.launch { | ||||
|             var result: Result = Result.NOTFOUND | ||||
|         /** | ||||
|          * Query image and setUp the view. | ||||
|          */ | ||||
|         fun queryAndSetView( | ||||
|             holder: ImageViewHolder, | ||||
|             image: Image, | ||||
|             ioDispatcher: CoroutineDispatcher, | ||||
|             defaultDispatcher: CoroutineDispatcher, | ||||
|             uploadedContributionsList: List<Contribution>, | ||||
|         ) { | ||||
|             /** | ||||
|              * Recycler view uses same view holder, so we can identify the latest query image from holder. | ||||
|              */ | ||||
|             mapHolderImage[holder] = image | ||||
|             holder.itemNotUploaded() | ||||
|             holder.itemForUpload() | ||||
|             holder.itemNotUploading() | ||||
| 
 | ||||
|             if (mapHolderImage[holder] != image) { | ||||
|                 return@launch | ||||
|             } | ||||
|             scope.launch { | ||||
|                 var result: Result = Result.NOTFOUND | ||||
| 
 | ||||
|             val imageSHA1: String = when (mapImageSHA1[image.uri] != null) { | ||||
|                 true -> mapImageSHA1[image.uri]!! | ||||
|                 else -> CustomSelectorUtils.getImageSHA1( | ||||
|                     image.uri, | ||||
|                     ioDispatcher, | ||||
|                     fileUtilsWrapper, | ||||
|                     context.contentResolver | ||||
|                 ) | ||||
|             } | ||||
|             mapImageSHA1[image.uri] = imageSHA1 | ||||
| 
 | ||||
|             if (imageSHA1.isEmpty()) { | ||||
|                 return@launch | ||||
|             } | ||||
|             val uploadedStatus = getFromUploaded(imageSHA1) | ||||
| 
 | ||||
|             val sha1 = uploadedStatus?.let { | ||||
|                 result = getResultFromUploadedStatus(uploadedStatus) | ||||
|                 uploadedStatus.modifiedImageSHA1 | ||||
|             } ?: run { | ||||
|                 if (mapHolderImage[holder] == image) { | ||||
|                     getSHA1(image, defaultDispatcher) | ||||
|                 } else { | ||||
|                     "" | ||||
|                 if (mapHolderImage[holder] != image) { | ||||
|                     return@launch | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (mapHolderImage[holder] != image) { | ||||
|                 return@launch | ||||
|             } | ||||
|                 val imageSHA1: String = | ||||
|                     when (mapImageSHA1[image.uri] != null) { | ||||
|                         true -> mapImageSHA1[image.uri]!! | ||||
|                         else -> | ||||
|                             CustomSelectorUtils.getImageSHA1( | ||||
|                                 image.uri, | ||||
|                                 ioDispatcher, | ||||
|                                 fileUtilsWrapper, | ||||
|                                 context.contentResolver, | ||||
|                             ) | ||||
|                     } | ||||
|                 mapImageSHA1[image.uri] = imageSHA1 | ||||
| 
 | ||||
|             val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1) | ||||
|                 if (imageSHA1.isEmpty()) { | ||||
|                     return@launch | ||||
|                 } | ||||
|                 val uploadedStatus = getFromUploaded(imageSHA1) | ||||
| 
 | ||||
|             if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) { | ||||
|                 when { | ||||
|                     mapResult[imageSHA1] == null -> { | ||||
|                         // Query original image. | ||||
|                         result = checkWhetherFileExistsOnCommonsUsingSHA1( | ||||
|                             imageSHA1, | ||||
|                             ioDispatcher, | ||||
|                             mediaClient | ||||
|                         ) | ||||
|                         when (result) { | ||||
|                             is Result.TRUE -> { | ||||
|                                 mapResult[imageSHA1] = Result.TRUE | ||||
|                             } | ||||
|                             is Result.ERROR -> { | ||||
|                                 mapResult[imageSHA1] = Result.ERROR | ||||
|                             } | ||||
|                             is Result.FALSE -> { | ||||
|                                 mapResult[imageSHA1] = Result.FALSE | ||||
|                             } | ||||
|                             is Result.INVALID -> { | ||||
|                                 mapResult[imageSHA1] = Result.INVALID | ||||
|                             } | ||||
|                             is Result.NOTFOUND -> { | ||||
|                                 mapResult[imageSHA1] = Result.NOTFOUND | ||||
|                             } | ||||
|                 val sha1 = | ||||
|                     uploadedStatus?.let { | ||||
|                         result = getResultFromUploadedStatus(uploadedStatus) | ||||
|                         uploadedStatus.modifiedImageSHA1 | ||||
|                     } ?: run { | ||||
|                         if (mapHolderImage[holder] == image) { | ||||
|                             getSHA1(image, defaultDispatcher) | ||||
|                         } else { | ||||
|                             "" | ||||
|                         } | ||||
|                     } | ||||
|                     else -> { | ||||
|                         result = mapResult[imageSHA1]!! | ||||
|                     } | ||||
| 
 | ||||
|                 if (mapHolderImage[holder] != image) { | ||||
|                     return@launch | ||||
|                 } | ||||
|                 if (result is Result.TRUE) { | ||||
|                     // Original image found. | ||||
|                     insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false) | ||||
|                 } else { | ||||
| 
 | ||||
|                 val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1) | ||||
| 
 | ||||
|                 if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) { | ||||
|                     when { | ||||
|                         mapResult[sha1] == null -> { | ||||
|                             // Original image not found, query modified image. | ||||
|                             result = checkWhetherFileExistsOnCommonsUsingSHA1( | ||||
|                                 sha1, | ||||
|                                 ioDispatcher, | ||||
|                                 mediaClient | ||||
|                             ) | ||||
|                         mapResult[imageSHA1] == null -> { | ||||
|                             // Query original image. | ||||
|                             result = | ||||
|                                 checkWhetherFileExistsOnCommonsUsingSHA1( | ||||
|                                     imageSHA1, | ||||
|                                     ioDispatcher, | ||||
|                                     mediaClient, | ||||
|                                 ) | ||||
|                             when (result) { | ||||
|                                 is Result.TRUE -> { | ||||
|                                     mapResult[sha1] = Result.TRUE | ||||
|                                     mapResult[imageSHA1] = Result.TRUE | ||||
|                                 } | ||||
|                                 is Result.ERROR -> { | ||||
|                                     mapResult[sha1] = Result.ERROR | ||||
|                                     mapResult[imageSHA1] = Result.ERROR | ||||
|                                 } | ||||
|                                 is Result.FALSE -> { | ||||
|                                     mapResult[sha1] = Result.FALSE | ||||
|                                     mapResult[imageSHA1] = Result.FALSE | ||||
|                                 } | ||||
|                                 is Result.INVALID -> { | ||||
|                                     mapResult[sha1] = Result.INVALID | ||||
|                                     mapResult[imageSHA1] = Result.INVALID | ||||
|                                 } | ||||
|                                 is Result.NOTFOUND -> { | ||||
|                                     mapResult[sha1] = Result.NOTFOUND | ||||
|                                     mapResult[imageSHA1] = Result.NOTFOUND | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         else -> { | ||||
|                             result = mapResult[sha1]!! | ||||
|                             result = mapResult[imageSHA1]!! | ||||
|                         } | ||||
|                     } | ||||
|                     if (result != Result.ERROR) { | ||||
|                         insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE) | ||||
|                     if (result is Result.TRUE) { | ||||
|                         // Original image found. | ||||
|                         insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false) | ||||
|                     } else { | ||||
|                         when { | ||||
|                             mapResult[sha1] == null -> { | ||||
|                                 // Original image not found, query modified image. | ||||
|                                 result = | ||||
|                                     checkWhetherFileExistsOnCommonsUsingSHA1( | ||||
|                                         sha1, | ||||
|                                         ioDispatcher, | ||||
|                                         mediaClient, | ||||
|                                     ) | ||||
|                                 when (result) { | ||||
|                                     is Result.TRUE -> { | ||||
|                                         mapResult[sha1] = Result.TRUE | ||||
|                                     } | ||||
|                                     is Result.ERROR -> { | ||||
|                                         mapResult[sha1] = Result.ERROR | ||||
|                                     } | ||||
|                                     is Result.FALSE -> { | ||||
|                                         mapResult[sha1] = Result.FALSE | ||||
|                                     } | ||||
|                                     is Result.INVALID -> { | ||||
|                                         mapResult[sha1] = Result.INVALID | ||||
|                                     } | ||||
|                                     is Result.NOTFOUND -> { | ||||
|                                         mapResult[sha1] = Result.NOTFOUND | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                             else -> { | ||||
|                                 result = mapResult[sha1]!! | ||||
|                             } | ||||
|                         } | ||||
|                         if (result != Result.ERROR) { | ||||
|                             insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             val sharedPreferences: SharedPreferences = | ||||
|                 context | ||||
|                     .getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0) | ||||
|             val showAlreadyActionedImages = | ||||
|                 sharedPreferences.getBoolean( | ||||
|                     ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, | ||||
|                     true | ||||
|                 ) | ||||
|                 val sharedPreferences: SharedPreferences = | ||||
|                     context | ||||
|                         .getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0) | ||||
|                 val showAlreadyActionedImages = | ||||
|                     sharedPreferences.getBoolean( | ||||
|                         ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, | ||||
|                         true, | ||||
|                     ) | ||||
| 
 | ||||
|             if (mapHolderImage[holder] == image) { | ||||
|                 if ((result is Result.TRUE) && showAlreadyActionedImages) { | ||||
|                     holder.itemUploaded() | ||||
|                 } else holder.itemNotUploaded() | ||||
| 
 | ||||
|                 if ((existsInNotForUploadTable > 0) && showAlreadyActionedImages) { | ||||
|                     holder.itemNotForUpload() | ||||
|                 } else holder.itemForUpload() | ||||
|             } | ||||
| 
 | ||||
|             if (uploadedContributionsList.isNotEmpty()) { | ||||
|                 for (contribution in uploadedContributionsList ) { | ||||
|                     if (contribution.contentUri == image.uri && showAlreadyActionedImages) { | ||||
|                         holder.itemUploading() | ||||
|                         break | ||||
|                 if (mapHolderImage[holder] == image) { | ||||
|                     if ((result is Result.TRUE) && showAlreadyActionedImages) { | ||||
|                         holder.itemUploaded() | ||||
|                     } else { | ||||
|                         holder.itemNotUploading() | ||||
|                         holder.itemNotUploaded() | ||||
|                     } | ||||
| 
 | ||||
|                     if ((existsInNotForUploadTable > 0) && showAlreadyActionedImages) { | ||||
|                         holder.itemNotForUpload() | ||||
|                     } else { | ||||
|                         holder.itemForUpload() | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 if (uploadedContributionsList.isNotEmpty()) { | ||||
|                     for (contribution in uploadedContributionsList) { | ||||
|                         if (contribution.contentUri == image.uri && showAlreadyActionedImages) { | ||||
|                             holder.itemUploading() | ||||
|                             break | ||||
|                         } else { | ||||
|                             holder.itemNotUploading() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Finds out the next actionable image position | ||||
|      */ | ||||
|     suspend fun nextActionableImage( | ||||
|         allImages: List<Image>, ioDispatcher: CoroutineDispatcher, | ||||
|         defaultDispatcher: CoroutineDispatcher, | ||||
|         nextImagePosition: Int, | ||||
|         currentlyUploadingImages: List<Contribution> | ||||
|     ): Int { | ||||
|         var next: Int | ||||
|         // Traversing from given position to the end | ||||
|         for (i in nextImagePosition until allImages.size){ | ||||
|             val currentImage = allImages[i] | ||||
|         /** | ||||
|          * Finds out the next actionable image position | ||||
|          */ | ||||
|         suspend fun nextActionableImage( | ||||
|             allImages: List<Image>, | ||||
|             ioDispatcher: CoroutineDispatcher, | ||||
|             defaultDispatcher: CoroutineDispatcher, | ||||
|             nextImagePosition: Int, | ||||
|             currentlyUploadingImages: List<Contribution>, | ||||
|         ): Int { | ||||
|             var next: Int | ||||
|             // Traversing from given position to the end | ||||
|             for (i in nextImagePosition until allImages.size) { | ||||
|                 val currentImage = allImages[i] | ||||
| 
 | ||||
|             if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) { | ||||
|                 continue // Skip this image as it's currently being uploaded | ||||
|             } | ||||
|                 if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) { | ||||
|                     continue // Skip this image as it's currently being uploaded | ||||
|                 } | ||||
| 
 | ||||
|             val imageSHA1: String = when (mapImageSHA1[currentImage.uri] != null) { | ||||
|                 true -> mapImageSHA1[currentImage.uri]!! | ||||
|                 else -> CustomSelectorUtils.getImageSHA1( | ||||
|                     currentImage.uri, | ||||
|                     ioDispatcher, | ||||
|                     fileUtilsWrapper, | ||||
|                     context.contentResolver | ||||
|                 ) | ||||
|             } | ||||
|             next = notForUploadStatusDao.find(imageSHA1) | ||||
|                 val imageSHA1: String = | ||||
|                     when (mapImageSHA1[currentImage.uri] != null) { | ||||
|                         true -> mapImageSHA1[currentImage.uri]!! | ||||
|                         else -> | ||||
|                             CustomSelectorUtils.getImageSHA1( | ||||
|                                 currentImage.uri, | ||||
|                                 ioDispatcher, | ||||
|                                 fileUtilsWrapper, | ||||
|                                 context.contentResolver, | ||||
|                             ) | ||||
|                     } | ||||
|                 next = notForUploadStatusDao.find(imageSHA1) | ||||
| 
 | ||||
|             // After checking the image in the not for upload table, if the image is present then | ||||
|             // skips the image and moves to next image for checking | ||||
|             if(next > 0){ | ||||
|                 continue | ||||
|                 // After checking the image in the not for upload table, if the image is present then | ||||
|                 // skips the image and moves to next image for checking | ||||
|                 if (next > 0) { | ||||
|                     continue | ||||
| 
 | ||||
|             // Otherwise checks in already uploaded table | ||||
|             } else { | ||||
|                 next = uploadedStatusDao.findByImageSHA1(imageSHA1, true) | ||||
|                     // Otherwise checks in already uploaded table | ||||
|                 } else { | ||||
|                     next = uploadedStatusDao.findByImageSHA1(imageSHA1, true) | ||||
| 
 | ||||
|                 // If the image is not present in the already uploaded table, checks for its | ||||
|                 // modified SHA1 in already uploaded table | ||||
|                 if (next <= 0) { | ||||
|                     val modifiedImageSha1 = getSHA1(currentImage, defaultDispatcher) | ||||
|                     next = uploadedStatusDao.findByModifiedImageSHA1( | ||||
|                         modifiedImageSha1, | ||||
|                         true | ||||
|                     ) | ||||
| 
 | ||||
|                     // If the modified image SHA1 is not present in the already uploaded table, | ||||
|                     // returns the position as next actionable image position | ||||
|                     // If the image is not present in the already uploaded table, checks for its | ||||
|                     // modified SHA1 in already uploaded table | ||||
|                     if (next <= 0) { | ||||
|                         return i | ||||
|                         val modifiedImageSha1 = getSHA1(currentImage, defaultDispatcher) | ||||
|                         next = | ||||
|                             uploadedStatusDao.findByModifiedImageSHA1( | ||||
|                                 modifiedImageSha1, | ||||
|                                 true, | ||||
|                             ) | ||||
| 
 | ||||
|                     // If present in the db then skips iteration for the image and moves to the next | ||||
|                     // for checking | ||||
|                         // If the modified image SHA1 is not present in the already uploaded table, | ||||
|                         // returns the position as next actionable image position | ||||
|                         if (next <= 0) { | ||||
|                             return i | ||||
| 
 | ||||
|                             // If present in the db then skips iteration for the image and moves to the next | ||||
|                             // for checking | ||||
|                         } else { | ||||
|                             continue | ||||
|                         } | ||||
| 
 | ||||
|                         // If present in the db then skips iteration for the image and moves to the next | ||||
|                         // for checking | ||||
|                     } else { | ||||
|                         continue | ||||
|                     } | ||||
| 
 | ||||
|                 // If present in the db then skips iteration for the image and moves to the next | ||||
|                 // for checking | ||||
|                 } else { | ||||
|                     continue | ||||
|                 } | ||||
|             } | ||||
|             return -1 | ||||
|         } | ||||
|         return -1 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get SHA1, return SHA1 if available, otherwise generate and store the SHA1. | ||||
|      * | ||||
|      * @return sha1 of the image | ||||
|      */ | ||||
|     suspend fun getSHA1(image: Image, defaultDispatcher: CoroutineDispatcher): String { | ||||
|         mapModifiedImageSHA1[image]?.let{ | ||||
|             return it | ||||
|         } | ||||
|         val sha1 = CustomSelectorUtils | ||||
|             .generateModifiedSHA1(image, | ||||
|                 defaultDispatcher, | ||||
|                 context, | ||||
|                 fileProcessor, | ||||
|                 fileUtilsWrapper | ||||
|             ) | ||||
|         mapModifiedImageSHA1[image] = sha1; | ||||
|         return sha1; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the uploaded status entry from the database. | ||||
|      */ | ||||
|     suspend fun getFromUploaded(imageSha1:String): UploadedStatus? { | ||||
|         return uploadedStatusDao.getUploadedFromImageSHA1(imageSha1) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Insert into uploaded status table. | ||||
|      */ | ||||
|     suspend fun insertIntoUploaded(imageSha1:String, modifiedImageSha1:String, imageResult:Boolean, modifiedImageResult: Boolean){ | ||||
|         uploadedStatusDao.insertUploaded( | ||||
|             UploadedStatus( | ||||
|                 imageSha1, | ||||
|                 modifiedImageSha1, | ||||
|                 imageResult, | ||||
|                 modifiedImageResult | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get result data from database. | ||||
|      */ | ||||
|     fun getResultFromUploadedStatus(uploadedStatus: UploadedStatus): Result { | ||||
|         if (uploadedStatus.imageResult || uploadedStatus.modifiedImageResult) { | ||||
|             return Result.TRUE | ||||
|         } else { | ||||
|             uploadedStatus.lastUpdated?.let { | ||||
|                 val duration = Calendar.getInstance().time.time - it.time | ||||
|                 if (TimeUnit.MILLISECONDS.toDays(duration) < INVALIDATE_DAY_COUNT) { | ||||
|                     return Result.FALSE | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return Result.INVALID | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sealed Result class. | ||||
|      */ | ||||
|     sealed class Result { | ||||
|         object TRUE : Result() | ||||
|         object FALSE : Result() | ||||
|         object INVALID : Result() | ||||
|         object NOTFOUND : Result() | ||||
|         object ERROR : Result() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Companion Object | ||||
|      */ | ||||
|     companion object { | ||||
|         /** | ||||
|          * Invalidate Day count. | ||||
|          * False Database Entries are invalid after INVALIDATE_DAY_COUNT and need to be re-queried. | ||||
|          * Get SHA1, return SHA1 if available, otherwise generate and store the SHA1. | ||||
|          * | ||||
|          * @return sha1 of the image | ||||
|          */ | ||||
|         const val INVALIDATE_DAY_COUNT: Long = 7 | ||||
|     } | ||||
|         suspend fun getSHA1( | ||||
|             image: Image, | ||||
|             defaultDispatcher: CoroutineDispatcher, | ||||
|         ): String { | ||||
|             mapModifiedImageSHA1[image]?.let { | ||||
|                 return it | ||||
|             } | ||||
|             val sha1 = | ||||
|                 CustomSelectorUtils | ||||
|                     .generateModifiedSHA1( | ||||
|                         image, | ||||
|                         defaultDispatcher, | ||||
|                         context, | ||||
|                         fileProcessor, | ||||
|                         fileUtilsWrapper, | ||||
|                     ) | ||||
|             mapModifiedImageSHA1[image] = sha1 | ||||
|             return sha1 | ||||
|         } | ||||
| 
 | ||||
| } | ||||
|         /** | ||||
|          * Get the uploaded status entry from the database. | ||||
|          */ | ||||
|         suspend fun getFromUploaded(imageSha1: String): UploadedStatus? = uploadedStatusDao.getUploadedFromImageSHA1(imageSha1) | ||||
| 
 | ||||
|         /** | ||||
|          * Insert into uploaded status table. | ||||
|          */ | ||||
|         suspend fun insertIntoUploaded( | ||||
|             imageSha1: String, | ||||
|             modifiedImageSha1: String, | ||||
|             imageResult: Boolean, | ||||
|             modifiedImageResult: Boolean, | ||||
|         ) { | ||||
|             uploadedStatusDao.insertUploaded( | ||||
|                 UploadedStatus( | ||||
|                     imageSha1, | ||||
|                     modifiedImageSha1, | ||||
|                     imageResult, | ||||
|                     modifiedImageResult, | ||||
|                 ), | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Get result data from database. | ||||
|          */ | ||||
|         fun getResultFromUploadedStatus(uploadedStatus: UploadedStatus): Result { | ||||
|             if (uploadedStatus.imageResult || uploadedStatus.modifiedImageResult) { | ||||
|                 return Result.TRUE | ||||
|             } else { | ||||
|                 uploadedStatus.lastUpdated?.let { | ||||
|                     val duration = Calendar.getInstance().time.time - it.time | ||||
|                     if (TimeUnit.MILLISECONDS.toDays(duration) < INVALIDATE_DAY_COUNT) { | ||||
|                         return Result.FALSE | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             return Result.INVALID | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Sealed Result class. | ||||
|          */ | ||||
|         sealed class Result { | ||||
|             object TRUE : Result() | ||||
| 
 | ||||
|             object FALSE : Result() | ||||
| 
 | ||||
|             object INVALID : Result() | ||||
| 
 | ||||
|             object NOTFOUND : Result() | ||||
| 
 | ||||
|             object ERROR : Result() | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * Companion Object | ||||
|          */ | ||||
|         companion object { | ||||
|             /** | ||||
|              * Invalidate Day count. | ||||
|              * False Database Entries are invalid after INVALIDATE_DAY_COUNT and need to be re-queried. | ||||
|              */ | ||||
|             const val INVALIDATE_DAY_COUNT: Long = 7 | ||||
|         } | ||||
|     } | ||||
|  |  | |||
|  | @ -17,13 +17,22 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao | |||
|  * The database for accessing the respective DAOs | ||||
|  * | ||||
|  */ | ||||
| @Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], version = 18, exportSchema = false) | ||||
| @Database( | ||||
|     entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], | ||||
|     version = 18, | ||||
|     exportSchema = false, | ||||
| ) | ||||
| @TypeConverters(Converters::class) | ||||
| abstract class AppDatabase : RoomDatabase() { | ||||
|     abstract fun contributionDao(): ContributionDao | ||||
| 
 | ||||
|     abstract fun PlaceDao(): PlaceDao | ||||
|     abstract fun DepictsDao(): DepictsDao; | ||||
|     abstract fun UploadedStatusDao(): UploadedStatusDao; | ||||
| 
 | ||||
|     abstract fun DepictsDao(): DepictsDao | ||||
| 
 | ||||
|     abstract fun UploadedStatusDao(): UploadedStatusDao | ||||
| 
 | ||||
|     abstract fun NotForUploadStatusDao(): NotForUploadStatusDao | ||||
| 
 | ||||
|     abstract fun ReviewDao(): ReviewDao | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| package fr.free.nrw.commons.description | ||||
| 
 | ||||
| 
 | ||||
| import android.app.ProgressDialog | ||||
| import android.content.Intent | ||||
| import android.os.Bundle | ||||
|  | @ -29,11 +28,12 @@ import io.reactivex.schedulers.Schedulers | |||
| import timber.log.Timber | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Activity for populating and editing existing description and caption | ||||
|  */ | ||||
| class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventListener { | ||||
| class DescriptionEditActivity : | ||||
|     BaseActivity(), | ||||
|     UploadMediaDetailAdapter.EventListener { | ||||
|     /** | ||||
|      * Adapter for showing UploadMediaDetail in the activity | ||||
|      */ | ||||
|  | @ -78,7 +78,6 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
| 
 | ||||
|     @Inject lateinit var sessionManager: SessionManager | ||||
| 
 | ||||
| 
 | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
| 
 | ||||
|  | @ -110,12 +109,17 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|      * @param descriptionAndCaptions list of description and caption | ||||
|      */ | ||||
|     private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) { | ||||
|         uploadMediaDetailAdapter = UploadMediaDetailAdapter(this, | ||||
|             savedLanguageValue, descriptionAndCaptions, recentLanguagesDao) | ||||
|         uploadMediaDetailAdapter = | ||||
|             UploadMediaDetailAdapter( | ||||
|                 this, | ||||
|                 savedLanguageValue, | ||||
|                 descriptionAndCaptions, | ||||
|                 recentLanguagesDao, | ||||
|             ) | ||||
|         uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int -> | ||||
|             showInfoAlert( | ||||
|                 titleStringID, | ||||
|                 messageStringId | ||||
|                 messageStringId, | ||||
|             ) | ||||
|         } | ||||
|         uploadMediaDetailAdapter.setEventListener(this) | ||||
|  | @ -129,11 +133,17 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|      * @param titleStringID Title ID | ||||
|      * @param messageStringId Message ID | ||||
|      */ | ||||
|     private fun showInfoAlert(titleStringID: Int, messageStringId: Int) { | ||||
|     private fun showInfoAlert( | ||||
|         titleStringID: Int, | ||||
|         messageStringId: Int, | ||||
|     ) { | ||||
|         showAlertDialog( | ||||
|             this, getString(titleStringID), | ||||
|             getString(messageStringId), getString(android.R.string.ok), | ||||
|             null, true | ||||
|             this, | ||||
|             getString(titleStringID), | ||||
|             getString(messageStringId), | ||||
|             getString(android.R.string.ok), | ||||
|             null, | ||||
|             true, | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|  | @ -144,13 +154,13 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|      */ | ||||
|     override fun addLanguage() { | ||||
|         val uploadMediaDetail = UploadMediaDetail() | ||||
|         uploadMediaDetail.isManuallyAdded = true //This was manually added by the user | ||||
|         uploadMediaDetail.isManuallyAdded = true // This was manually added by the user | ||||
|         uploadMediaDetailAdapter.addDescription(uploadMediaDetail) | ||||
|         rvDescriptions!!.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1) | ||||
|     } | ||||
| 
 | ||||
|     private fun onBackButtonClicked(view: View) { | ||||
|        onBackPressedDispatcher.onBackPressed() | ||||
|         onBackPressedDispatcher.onBackPressed() | ||||
|     } | ||||
| 
 | ||||
|     private fun onSubmitButtonClicked(view: View) { | ||||
|  | @ -174,10 +184,11 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|             val descriptionStart = wikiText!!.substring(0, descriptionIndex + 12) | ||||
|             val descriptionToEnd = wikiText!!.substring(descriptionIndex + 12) | ||||
|             val descriptionEndIndex = descriptionToEnd.indexOf("\n") | ||||
|             val descriptionEnd = wikiText!!.substring( | ||||
|                 descriptionStart.length | ||||
|                         + descriptionEndIndex | ||||
|             ) | ||||
|             val descriptionEnd = | ||||
|                 wikiText!!.substring( | ||||
|                     descriptionStart.length + | ||||
|                         descriptionEndIndex, | ||||
|                 ) | ||||
|             buffer.append(descriptionStart) | ||||
|             for (i in uploadMediaDetails.indices) { | ||||
|                 val uploadDetails = uploadMediaDetails[i] | ||||
|  | @ -203,65 +214,72 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|      * @param updatedWikiText updated wiki text | ||||
|      * @param uploadMediaDetails descriptions and captions | ||||
|      */ | ||||
|     private fun editDescription(media : Media, updatedWikiText : String, uploadMediaDetails : ArrayList<UploadMediaDetail>){ | ||||
| 
 | ||||
|     private fun editDescription( | ||||
|         media: Media, | ||||
|         updatedWikiText: String, | ||||
|         uploadMediaDetails: ArrayList<UploadMediaDetail>, | ||||
|     ) { | ||||
|         try { | ||||
|             descriptionEditHelper?.addDescription( | ||||
|                 applicationContext, media, | ||||
|                 updatedWikiText | ||||
|             ) | ||||
|                 ?.subscribeOn(Schedulers.io()) | ||||
|             descriptionEditHelper | ||||
|                 ?.addDescription( | ||||
|                     applicationContext, | ||||
|                     media, | ||||
|                     updatedWikiText, | ||||
|                 )?.subscribeOn(Schedulers.io()) | ||||
|                 ?.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 ?.subscribe(Consumer<Boolean> { s: Boolean? -> Timber.d("Descriptions are added.") })?.let { | ||||
|                 ?.subscribe(Consumer<Boolean> { s: Boolean? -> Timber.d("Descriptions are added.") }) | ||||
|                 ?.let { | ||||
|                     compositeDisposable.add( | ||||
|                         it | ||||
|                         it, | ||||
|                     ) | ||||
|                 } | ||||
|         } catch (e : InvalidLoginTokenException) { | ||||
|         } catch (e: InvalidLoginTokenException) { | ||||
|             val username: String? = sessionManager?.userName | ||||
|             val logoutListener = CommonsApplication.BaseLogoutListener( | ||||
|                 this, | ||||
|                 getString(R.string.invalid_login_message), | ||||
|                 username | ||||
|             ) | ||||
|             val logoutListener = | ||||
|                 CommonsApplication.BaseLogoutListener( | ||||
|                     this, | ||||
|                     getString(R.string.invalid_login_message), | ||||
|                     username, | ||||
|                 ) | ||||
| 
 | ||||
|             val commonsApplication = CommonsApplication.getInstance() | ||||
|             if (commonsApplication != null ){ | ||||
|                 commonsApplication.clearApplicationData(this,logoutListener) | ||||
|             if (commonsApplication != null) { | ||||
|                 commonsApplication.clearApplicationData(this, logoutListener) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         val updatedCaptions = LinkedHashMap<String, String>() | ||||
|         for (mediaDetail in uploadMediaDetails) { | ||||
|             try { | ||||
|                 compositeDisposable.add( | ||||
|                     descriptionEditHelper!!.addCaption( | ||||
|                         applicationContext, media, | ||||
|                         mediaDetail.languageCode, mediaDetail.captionText | ||||
|                     ) | ||||
|                         .subscribeOn(Schedulers.io()) | ||||
|                     descriptionEditHelper!! | ||||
|                         .addCaption( | ||||
|                             applicationContext, | ||||
|                             media, | ||||
|                             mediaDetail.languageCode, | ||||
|                             mediaDetail.captionText, | ||||
|                         ).subscribeOn(Schedulers.io()) | ||||
|                         .observeOn(AndroidSchedulers.mainThread()) | ||||
|                         .subscribe { s: Boolean? -> | ||||
|                             updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText | ||||
|                             media.captions = updatedCaptions | ||||
|                             Timber.d("Caption is added.") | ||||
|                         }) | ||||
|             } | ||||
|             catch (e : InvalidLoginTokenException) { | ||||
|                 val username = sessionManager.userName | ||||
|                 val logoutListener = CommonsApplication.BaseLogoutListener( | ||||
|                     this, | ||||
|                     getString(R.string.invalid_login_message), | ||||
|                     username | ||||
|                         }, | ||||
|                 ) | ||||
|             } catch (e: InvalidLoginTokenException) { | ||||
|                 val username = sessionManager.userName | ||||
|                 val logoutListener = | ||||
|                     CommonsApplication.BaseLogoutListener( | ||||
|                         this, | ||||
|                         getString(R.string.invalid_login_message), | ||||
|                         username, | ||||
|                     ) | ||||
| 
 | ||||
|                 val commonsApplication = CommonsApplication.getInstance() | ||||
|                 if (commonsApplication != null ){ | ||||
|                     commonsApplication.clearApplicationData(this,logoutListener) | ||||
|                 if (commonsApplication != null) { | ||||
|                     commonsApplication.clearApplicationData(this, logoutListener) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -274,23 +292,29 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi | |||
|         progressDialog!!.show() | ||||
|     } | ||||
| 
 | ||||
|     override | ||||
|     fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { | ||||
|         super.onActivityResult(requestCode, resultCode, data); | ||||
|     override fun onActivityResult( | ||||
|         requestCode: Int, | ||||
|         resultCode: Int, | ||||
|         data: Intent?, | ||||
|     ) { | ||||
|         super.onActivityResult(requestCode, resultCode, data) | ||||
|         if (requestCode == REQUEST_CODE_FOR_VOICE_INPUT) { | ||||
|             if (resultCode == RESULT_OK && data != null) { | ||||
|                 val result = data.getStringArrayListExtra( RecognizerIntent.EXTRA_RESULTS ) | ||||
|                 uploadMediaDetailAdapter.handleSpeechResult(result!![0]) } | ||||
|             else { Timber.e("Error %s", resultCode) } | ||||
|                 val result = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) | ||||
|                 uploadMediaDetailAdapter.handleSpeechResult(result!![0]) | ||||
|             } else { | ||||
|                 Timber.e("Error %s", resultCode) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun onSaveInstanceState(outState: Bundle) { | ||||
|         super.onSaveInstanceState(outState) | ||||
| 
 | ||||
|         outState.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, uploadMediaDetailAdapter.items as ArrayList<out Parcelable?>) | ||||
|         outState.putString(WIKITEXT, wikiText) | ||||
|         outState.putString(Prefs.DESCRIPTION_LANGUAGE, savedLanguageValue) | ||||
|         //save Media | ||||
|         // save Media | ||||
|         outState.putParcelable("media", media) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -6,5 +6,5 @@ package fr.free.nrw.commons.description | |||
| object EditDescriptionConstants { | ||||
|     const val LIST_OF_DESCRIPTION_AND_CAPTION = "description.descriptionAndCaption" | ||||
|     const val WIKITEXT = "description.wikiText" | ||||
|     const val UPDATED_WIKITEXT = "description.updatedWikiText"; | ||||
| } | ||||
|     const val UPDATED_WIKITEXT = "description.updatedWikiText" | ||||
| } | ||||
|  |  | |||
|  | @ -6,8 +6,7 @@ import dagger.Provides | |||
| import fr.free.nrw.commons.explore.map.ExploreMapFragment | ||||
| 
 | ||||
| @Module | ||||
| class ExploreMapFragmentModule{ | ||||
| 
 | ||||
| class ExploreMapFragmentModule { | ||||
|     @Provides | ||||
|     fun ExploreMapFragment.providesActivity(): Activity = activity!! | ||||
| } | ||||
|  |  | |||
|  | @ -6,8 +6,7 @@ import dagger.Provides | |||
| import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment | ||||
| 
 | ||||
| @Module | ||||
| class NearbyParentFragmentModule{ | ||||
| 
 | ||||
| class NearbyParentFragmentModule { | ||||
|     @Provides | ||||
|     fun NearbyParentFragment.providesActivity(): Activity = activity!! | ||||
| } | ||||
|  |  | |||
|  | @ -44,31 +44,32 @@ class EditActivity : AppCompatActivity() { | |||
|         imageUri = intent.getStringExtra("image") ?: "" | ||||
|         vm = ViewModelProvider(this).get(EditViewModel::class.java) | ||||
|         val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } | ||||
|         val exifTags = arrayOf( | ||||
|             ExifInterface.TAG_APERTURE, | ||||
|             ExifInterface.TAG_DATETIME, | ||||
|             ExifInterface.TAG_EXPOSURE_TIME, | ||||
|             ExifInterface.TAG_FLASH, | ||||
|             ExifInterface.TAG_FOCAL_LENGTH, | ||||
|             ExifInterface.TAG_GPS_ALTITUDE, | ||||
|             ExifInterface.TAG_GPS_ALTITUDE_REF, | ||||
|             ExifInterface.TAG_GPS_DATESTAMP, | ||||
|             ExifInterface.TAG_GPS_LATITUDE, | ||||
|             ExifInterface.TAG_GPS_LATITUDE_REF, | ||||
|             ExifInterface.TAG_GPS_LONGITUDE, | ||||
|             ExifInterface.TAG_GPS_LONGITUDE_REF, | ||||
|             ExifInterface.TAG_GPS_PROCESSING_METHOD, | ||||
|             ExifInterface.TAG_GPS_TIMESTAMP, | ||||
|             ExifInterface.TAG_IMAGE_LENGTH, | ||||
|             ExifInterface.TAG_IMAGE_WIDTH, | ||||
|             ExifInterface.TAG_ISO, | ||||
|             ExifInterface.TAG_MAKE, | ||||
|             ExifInterface.TAG_MODEL, | ||||
|             ExifInterface.TAG_ORIENTATION, | ||||
|             ExifInterface.TAG_WHITE_BALANCE, | ||||
|             ExifInterface.WHITEBALANCE_AUTO, | ||||
|             ExifInterface.WHITEBALANCE_MANUAL | ||||
|         ) | ||||
|         val exifTags = | ||||
|             arrayOf( | ||||
|                 ExifInterface.TAG_APERTURE, | ||||
|                 ExifInterface.TAG_DATETIME, | ||||
|                 ExifInterface.TAG_EXPOSURE_TIME, | ||||
|                 ExifInterface.TAG_FLASH, | ||||
|                 ExifInterface.TAG_FOCAL_LENGTH, | ||||
|                 ExifInterface.TAG_GPS_ALTITUDE, | ||||
|                 ExifInterface.TAG_GPS_ALTITUDE_REF, | ||||
|                 ExifInterface.TAG_GPS_DATESTAMP, | ||||
|                 ExifInterface.TAG_GPS_LATITUDE, | ||||
|                 ExifInterface.TAG_GPS_LATITUDE_REF, | ||||
|                 ExifInterface.TAG_GPS_LONGITUDE, | ||||
|                 ExifInterface.TAG_GPS_LONGITUDE_REF, | ||||
|                 ExifInterface.TAG_GPS_PROCESSING_METHOD, | ||||
|                 ExifInterface.TAG_GPS_TIMESTAMP, | ||||
|                 ExifInterface.TAG_IMAGE_LENGTH, | ||||
|                 ExifInterface.TAG_IMAGE_WIDTH, | ||||
|                 ExifInterface.TAG_ISO, | ||||
|                 ExifInterface.TAG_MAKE, | ||||
|                 ExifInterface.TAG_MODEL, | ||||
|                 ExifInterface.TAG_ORIENTATION, | ||||
|                 ExifInterface.TAG_WHITE_BALANCE, | ||||
|                 ExifInterface.WHITEBALANCE_AUTO, | ||||
|                 ExifInterface.WHITEBALANCE_MANUAL, | ||||
|             ) | ||||
|         for (tag in exifTags) { | ||||
|             val attribute = sourceExif?.getAttribute(tag.toString()) | ||||
|             sourceExifAttributeList.add(Pair(tag.toString(), attribute)) | ||||
|  | @ -87,37 +88,38 @@ class EditActivity : AppCompatActivity() { | |||
|     private fun init() { | ||||
|         binding.iv.adjustViewBounds = true | ||||
|         binding.iv.scaleType = ImageView.ScaleType.MATRIX | ||||
|         binding.iv.post(Runnable { | ||||
|             val options = BitmapFactory.Options() | ||||
|             options.inJustDecodeBounds = true | ||||
|             BitmapFactory.decodeFile(imageUri, options) | ||||
|         binding.iv.post( | ||||
|             Runnable { | ||||
|                 val options = BitmapFactory.Options() | ||||
|                 options.inJustDecodeBounds = true | ||||
|                 BitmapFactory.decodeFile(imageUri, options) | ||||
| 
 | ||||
|             val bitmapWidth = options.outWidth | ||||
|             val bitmapHeight = options.outHeight | ||||
|                 val bitmapWidth = options.outWidth | ||||
|                 val bitmapHeight = options.outHeight | ||||
| 
 | ||||
|             // Check if the bitmap dimensions exceed a certain threshold | ||||
|             val maxBitmapSize = 2000 // Set your maximum size here | ||||
|             if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { | ||||
|                 val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) | ||||
|                 options.inSampleSize = scaleFactor | ||||
|                 options.inJustDecodeBounds = false | ||||
|                 val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) | ||||
|                 binding.iv.setImageBitmap(scaledBitmap) | ||||
|                 // Update the ImageView with the scaled bitmap | ||||
|                 val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() | ||||
|                 binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() | ||||
|                 binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||
|             } else { | ||||
|                 // Check if the bitmap dimensions exceed a certain threshold | ||||
|                 val maxBitmapSize = 2000 // Set your maximum size here | ||||
|                 if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { | ||||
|                     val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) | ||||
|                     options.inSampleSize = scaleFactor | ||||
|                     options.inJustDecodeBounds = false | ||||
|                     val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) | ||||
|                     binding.iv.setImageBitmap(scaledBitmap) | ||||
|                     // Update the ImageView with the scaled bitmap | ||||
|                     val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() | ||||
|                     binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() | ||||
|                     binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||
|                 } else { | ||||
|                     options.inJustDecodeBounds = false | ||||
|                     val bitmap = BitmapFactory.decodeFile(imageUri, options) | ||||
|                     binding.iv.setImageBitmap(bitmap) | ||||
| 
 | ||||
|                 options.inJustDecodeBounds = false | ||||
|                 val bitmap = BitmapFactory.decodeFile(imageUri, options) | ||||
|                 binding.iv.setImageBitmap(bitmap) | ||||
| 
 | ||||
|                 val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() | ||||
|                 binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() | ||||
|                 binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||
|             } | ||||
|         }) | ||||
|                     val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat() | ||||
|                     binding.iv.layoutParams.height = (scale * bitmapHeight).toInt() | ||||
|                     binding.iv.imageMatrix = scaleMatrix(scale, scale) | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|         binding.rotateBtn.setOnClickListener { | ||||
|             animateImageHeight() | ||||
|         } | ||||
|  | @ -138,8 +140,16 @@ class EditActivity : AppCompatActivity() { | |||
|      * further rotation actions. | ||||
|      */ | ||||
|     private fun animateImageHeight() { | ||||
|         val drawableWidth: Float = binding.iv.getDrawable().getIntrinsicWidth().toFloat() | ||||
|         val drawableHeight: Float = binding.iv.getDrawable().getIntrinsicHeight().toFloat() | ||||
|         val drawableWidth: Float = | ||||
|             binding.iv | ||||
|                 .getDrawable() | ||||
|                 .getIntrinsicWidth() | ||||
|                 .toFloat() | ||||
|         val drawableHeight: Float = | ||||
|             binding.iv | ||||
|                 .getDrawable() | ||||
|                 .getIntrinsicHeight() | ||||
|                 .toFloat() | ||||
|         val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() | ||||
|         val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() | ||||
|         val rotation = imageRotation % 360 | ||||
|  | @ -152,7 +162,6 @@ class EditActivity : AppCompatActivity() { | |||
|         Timber.d("Rotation $rotation") | ||||
|         Timber.d("new Rotation $newRotation") | ||||
| 
 | ||||
| 
 | ||||
|         if (rotation == 0 || rotation == 180) { | ||||
|             imageScale = viewWidth / drawableWidth | ||||
|             newImageScale = viewWidth / drawableHeight | ||||
|  | @ -169,23 +178,24 @@ class EditActivity : AppCompatActivity() { | |||
| 
 | ||||
|         animator.interpolator = AccelerateDecelerateInterpolator() | ||||
| 
 | ||||
|         animator.addListener(object : AnimatorListener { | ||||
|             override fun onAnimationStart(animation: Animator) { | ||||
|                 binding.rotateBtn.setEnabled(false) | ||||
|             } | ||||
|         animator.addListener( | ||||
|             object : AnimatorListener { | ||||
|                 override fun onAnimationStart(animation: Animator) { | ||||
|                     binding.rotateBtn.setEnabled(false) | ||||
|                 } | ||||
| 
 | ||||
|             override fun onAnimationEnd(animation: Animator) { | ||||
|                 imageRotation = newRotation % 360 | ||||
|                 binding.rotateBtn.setEnabled(true) | ||||
|             } | ||||
|                 override fun onAnimationEnd(animation: Animator) { | ||||
|                     imageRotation = newRotation % 360 | ||||
|                     binding.rotateBtn.setEnabled(true) | ||||
|                 } | ||||
| 
 | ||||
|             override fun onAnimationCancel(animation: Animator) { | ||||
|             } | ||||
|                 override fun onAnimationCancel(animation: Animator) { | ||||
|                 } | ||||
| 
 | ||||
|             override fun onAnimationRepeat(animation: Animator) { | ||||
|             } | ||||
| 
 | ||||
|         }) | ||||
|                 override fun onAnimationRepeat(animation: Animator) { | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
| 
 | ||||
|         animator.addUpdateListener { animation -> | ||||
|             val animVal = animation.animatedValue as Float | ||||
|  | @ -195,20 +205,21 @@ class EditActivity : AppCompatActivity() { | |||
|             val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale | ||||
|             val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation | ||||
|             binding.iv.getLayoutParams().height = animatedHeight | ||||
|             val matrix: Matrix = rotationMatrix( | ||||
|                 animatedRotation, | ||||
|                 drawableWidth / 2, | ||||
|                 drawableHeight / 2 | ||||
|             ) | ||||
|             val matrix: Matrix = | ||||
|                 rotationMatrix( | ||||
|                     animatedRotation, | ||||
|                     drawableWidth / 2, | ||||
|                     drawableHeight / 2, | ||||
|                 ) | ||||
|             matrix.postScale( | ||||
|                 animatedScale, | ||||
|                 animatedScale, | ||||
|                 drawableWidth / 2, | ||||
|                 drawableHeight / 2 | ||||
|                 drawableHeight / 2, | ||||
|             ) | ||||
|             matrix.postTranslate( | ||||
|                 -(drawableWidth - binding.iv.getMeasuredWidth()) / 2, | ||||
|                 -(drawableHeight - binding.iv.getMeasuredHeight()) / 2 | ||||
|                 -(drawableHeight - binding.iv.getMeasuredHeight()) / 2, | ||||
|             ) | ||||
|             binding.iv.setImageMatrix(matrix) | ||||
|             binding.iv.requestLayout() | ||||
|  | @ -228,11 +239,9 @@ class EditActivity : AppCompatActivity() { | |||
|      * as a result, and finishes the current activity. | ||||
|      */ | ||||
|     fun getRotatedImage() { | ||||
| 
 | ||||
|         val filePath = imageUri.toUri().path | ||||
|         val file = filePath?.let { File(it) } | ||||
| 
 | ||||
| 
 | ||||
|         val rotatedImage = file?.let { vm.rotateImage(imageRotation, it) } | ||||
|         if (rotatedImage == null) { | ||||
|             Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show() | ||||
|  | @ -243,9 +252,9 @@ class EditActivity : AppCompatActivity() { | |||
|             copyExifData(editedImageExif) | ||||
|         } | ||||
|         val resultIntent = Intent() | ||||
|         resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error"); | ||||
|         setResult(RESULT_OK, resultIntent); | ||||
|         finish(); | ||||
|         resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error") | ||||
|         setResult(RESULT_OK, resultIntent) | ||||
|         finish() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -257,7 +266,6 @@ class EditActivity : AppCompatActivity() { | |||
|      * @param editedImageExif The ExifInterface object for the edited image. | ||||
|      */ | ||||
|     private fun copyExifData(editedImageExif: ExifInterface?) { | ||||
| 
 | ||||
|         for (attr in sourceExifAttributeList) { | ||||
|             Log.d("Tag is  ${attr.first}", "Value is ${attr.second}") | ||||
|             editedImageExif!!.setAttribute(attr.first, attr.second) | ||||
|  | @ -282,7 +290,11 @@ class EditActivity : AppCompatActivity() { | |||
|      *         The scale factor ensures that the scaled bitmap will fit within the maximum size | ||||
|      *         while maintaining aspect ratio. | ||||
|      */ | ||||
|     private fun calculateScaleFactor(originalWidth: Int, originalHeight: Int, maxSize: Int): Int { | ||||
|     private fun calculateScaleFactor( | ||||
|         originalWidth: Int, | ||||
|         originalHeight: Int, | ||||
|         maxSize: Int, | ||||
|     ): Int { | ||||
|         var scaleFactor = 1 | ||||
| 
 | ||||
|         if (originalWidth > maxSize || originalHeight > maxSize) { | ||||
|  | @ -295,7 +307,4 @@ class EditActivity : AppCompatActivity() { | |||
| 
 | ||||
|         return scaleFactor | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -9,8 +9,7 @@ import java.io.File | |||
|  * This ViewModel class is responsible for managing image editing operations, such as | ||||
|  * rotating images. It utilizes a TransformImage implementation to perform image transformations. | ||||
|  */ | ||||
| class EditViewModel() : ViewModel() { | ||||
| 
 | ||||
| class EditViewModel : ViewModel() { | ||||
|     // Ideally should be injected using DI | ||||
|     private val transformImage: TransformImage = TransformImageImpl() | ||||
| 
 | ||||
|  | @ -21,7 +20,8 @@ class EditViewModel() : ViewModel() { | |||
|      * @param imageFile The File representing the image to be rotated. | ||||
|      * @return The rotated image File, or null if the rotation operation fails. | ||||
|      */ | ||||
|     fun rotateImage(degree: Int, imageFile: File): File? { | ||||
|         return transformImage.rotateImage(imageFile, degree) | ||||
|     } | ||||
| } | ||||
|     fun rotateImage( | ||||
|         degree: Int, | ||||
|         imageFile: File, | ||||
|     ): File? = transformImage.rotateImage(imageFile, degree) | ||||
| } | ||||
|  |  | |||
|  | @ -9,7 +9,6 @@ import java.io.File | |||
|  * implementations to provide specific functionality for tasks like rotating images. | ||||
|  */ | ||||
| interface TransformImage { | ||||
| 
 | ||||
|     /** | ||||
|      * Rotates the specified image file by the given degree. | ||||
|      * | ||||
|  | @ -17,5 +16,8 @@ interface TransformImage { | |||
|      * @param degree The degree by which to rotate the image. | ||||
|      * @return The rotated image File, or null if the rotation operation fails. | ||||
|      */ | ||||
|     fun rotateImage(imageFile: File, degree : Int ):File? | ||||
| } | ||||
|     fun rotateImage( | ||||
|         imageFile: File, | ||||
|         degree: Int, | ||||
|     ): File? | ||||
| } | ||||
|  |  | |||
|  | @ -15,8 +15,7 @@ import java.io.FileOutputStream | |||
|  * function for rotating images by a specified degree using the LLJTran library. Right now it reads | ||||
|  * the input image file, performs the rotation, and saves the rotated image to a new file. | ||||
|  */ | ||||
| class TransformImageImpl() : TransformImage { | ||||
| 
 | ||||
| class TransformImageImpl : TransformImage { | ||||
|     /** | ||||
|      * Rotates the specified image file by the given degree. | ||||
|      * | ||||
|  | @ -24,46 +23,50 @@ class TransformImageImpl() : TransformImage { | |||
|      * @param degree The degree by which to rotate the image. | ||||
|      * @return The rotated image File, or null if the rotation operation fails. | ||||
|      */ | ||||
|     override fun rotateImage(imageFile: File, degree : Int): File? { | ||||
| 
 | ||||
|     override fun rotateImage( | ||||
|         imageFile: File, | ||||
|         degree: Int, | ||||
|     ): File? { | ||||
|         Timber.tag("Trying to rotate image").d("Starting") | ||||
| 
 | ||||
|         val path = Environment.getExternalStoragePublicDirectory( | ||||
|             Environment.DIRECTORY_DOWNLOADS | ||||
|         ) | ||||
|         val path = | ||||
|             Environment.getExternalStoragePublicDirectory( | ||||
|                 Environment.DIRECTORY_DOWNLOADS, | ||||
|             ) | ||||
| 
 | ||||
|         val imagePath = System.currentTimeMillis() | ||||
|         val file: File = File(path, "$imagePath.jpg") | ||||
| 
 | ||||
|         val output = file | ||||
| 
 | ||||
|         val rotated = try { | ||||
|             val lljTran = LLJTran(imageFile) | ||||
|             lljTran.read( | ||||
|                 LLJTran.READ_ALL, | ||||
|                 false, | ||||
|             ) // This could throw an LLJTranException. I am not catching it for now... Let's see. | ||||
|             lljTran.transform( | ||||
|                 when(degree){ | ||||
|                          90 -> LLJTran.ROT_90 | ||||
|                          180 -> LLJTran.ROT_180 | ||||
|                          270 -> LLJTran.ROT_270 | ||||
|                     else -> { | ||||
|                       LLJTran.ROT_90 | ||||
|                     } | ||||
|                 }, | ||||
|                 LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION | ||||
|             ) | ||||
|             BufferedOutputStream(FileOutputStream(output)).use { writer -> | ||||
|                 lljTran.save(writer, LLJTran.OPT_WRITE_ALL ) | ||||
|         val rotated = | ||||
|             try { | ||||
|                 val lljTran = LLJTran(imageFile) | ||||
|                 lljTran.read( | ||||
|                     LLJTran.READ_ALL, | ||||
|                     false, | ||||
|                 ) // This could throw an LLJTranException. I am not catching it for now... Let's see. | ||||
|                 lljTran.transform( | ||||
|                     when (degree) { | ||||
|                         90 -> LLJTran.ROT_90 | ||||
|                         180 -> LLJTran.ROT_180 | ||||
|                         270 -> LLJTran.ROT_270 | ||||
|                         else -> { | ||||
|                             LLJTran.ROT_90 | ||||
|                         } | ||||
|                     }, | ||||
|                     LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION, | ||||
|                 ) | ||||
|                 BufferedOutputStream(FileOutputStream(output)).use { writer -> | ||||
|                     lljTran.save(writer, LLJTran.OPT_WRITE_ALL) | ||||
|                 } | ||||
|                 lljTran.freeMemory() | ||||
|                 true | ||||
|             } catch (e: LLJTranException) { | ||||
|                 Timber.tag("Error").d(e) | ||||
|                 return null | ||||
|                 false | ||||
|             } | ||||
|             lljTran.freeMemory() | ||||
|             true | ||||
|         } catch (e: LLJTranException) { | ||||
|             Timber.tag("Error").d(e) | ||||
|             return null | ||||
|             false | ||||
|         } | ||||
| 
 | ||||
|         if (rotated) { | ||||
|             Timber.tag("Done rotating image").d("Done") | ||||
|  |  | |||
|  | @ -15,14 +15,11 @@ import fr.free.nrw.commons.explore.media.SearchMediaFragmentPresenterImpl | |||
| @Module | ||||
| abstract class SearchModule { | ||||
|     @Binds | ||||
|     abstract fun SearchDepictionsFragmentPresenterImpl.bindsSearchDepictionsFragmentPresenter() | ||||
|             : SearchDepictionsFragmentPresenter | ||||
|     abstract fun SearchDepictionsFragmentPresenterImpl.bindsSearchDepictionsFragmentPresenter(): SearchDepictionsFragmentPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun SearchCategoriesFragmentPresenterImpl.bindsSearchCategoriesFragmentPresenter() | ||||
|             : SearchCategoriesFragmentPresenter | ||||
|     abstract fun SearchCategoriesFragmentPresenterImpl.bindsSearchCategoriesFragmentPresenter(): SearchCategoriesFragmentPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun SearchMediaFragmentPresenterImpl.bindsSearchMediaFragmentPresenter() | ||||
|             : SearchMediaFragmentPresenter | ||||
|     abstract fun SearchMediaFragmentPresenterImpl.bindsSearchMediaFragmentPresenter(): SearchMediaFragmentPresenter | ||||
| } | ||||
|  |  | |||
|  | @ -9,19 +9,14 @@ import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesPresenterIm | |||
| import fr.free.nrw.commons.explore.categories.sub.SubCategoriesPresenter | ||||
| import fr.free.nrw.commons.explore.categories.sub.SubCategoriesPresenterImpl | ||||
| 
 | ||||
| 
 | ||||
| @Module | ||||
| abstract class CategoriesModule { | ||||
|     @Binds | ||||
|     abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter(): CategoryMediaPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter() | ||||
|             : CategoryMediaPresenter | ||||
|     abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter(): SubCategoriesPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter() | ||||
|             : SubCategoriesPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter() | ||||
|             : ParentCategoriesPresenter | ||||
|     abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter(): ParentCategoriesPresenter | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import fr.free.nrw.commons.R | |||
| import fr.free.nrw.commons.category.CategoryDetailsActivity | ||||
| import fr.free.nrw.commons.explore.paging.BasePagingFragment | ||||
| 
 | ||||
| 
 | ||||
| abstract class PageableCategoryFragment : BasePagingFragment<String>() { | ||||
|     override val errorTextId: Int = R.string.error_loading_categories | ||||
|     override val pagedListAdapter by lazy { | ||||
|  |  | |||
|  | @ -8,31 +8,44 @@ import androidx.recyclerview.widget.RecyclerView | |||
| import fr.free.nrw.commons.category.CATEGORY_PREFIX | ||||
| import fr.free.nrw.commons.databinding.ItemRecentSearchesBinding | ||||
| 
 | ||||
| class PagedSearchCategoriesAdapter(private val onCategoryClicked: (String) -> Unit) : | ||||
|     PagedListAdapter<String, CategoryItemViewHolder>(PagedSearchCategoriesDiffUtilCallback) { | ||||
| 
 | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = CategoryItemViewHolder( | ||||
|         ItemRecentSearchesBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
| class PagedSearchCategoriesAdapter( | ||||
|     private val onCategoryClicked: (String) -> Unit, | ||||
| ) : PagedListAdapter<String, CategoryItemViewHolder>(PagedSearchCategoriesDiffUtilCallback) { | ||||
|     override fun onCreateViewHolder( | ||||
|         parent: ViewGroup, | ||||
|         viewType: Int, | ||||
|     ) = CategoryItemViewHolder( | ||||
|         ItemRecentSearchesBinding.inflate(LayoutInflater.from(parent.context), parent, false), | ||||
|     ) | ||||
| 
 | ||||
|     override fun onBindViewHolder(holder: CategoryItemViewHolder, position: Int) { | ||||
|     override fun onBindViewHolder( | ||||
|         holder: CategoryItemViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         holder.bind(getItem(position)!!, onCategoryClicked) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class CategoryItemViewHolder( | ||||
|     private val binding: ItemRecentSearchesBinding | ||||
|     private val binding: ItemRecentSearchesBinding, | ||||
| ) : RecyclerView.ViewHolder(binding.root) { | ||||
|     fun bind(item: String, onCategoryClicked: (String) -> Unit) = with(binding) { | ||||
|     fun bind( | ||||
|         item: String, | ||||
|         onCategoryClicked: (String) -> Unit, | ||||
|     ) = with(binding) { | ||||
|         root.setOnClickListener { onCategoryClicked(item) } | ||||
|         textView1.text = item.substringAfter(CATEGORY_PREFIX) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| private object PagedSearchCategoriesDiffUtilCallback : DiffUtil.ItemCallback<String>() { | ||||
|     override fun areItemsTheSame(oldItem: String, newItem: String) = | ||||
|         oldItem == newItem | ||||
|     override fun areItemsTheSame( | ||||
|         oldItem: String, | ||||
|         newItem: String, | ||||
|     ) = oldItem == newItem | ||||
| 
 | ||||
|     override fun areContentsTheSame(oldItem: String, newItem: String) = | ||||
|         oldItem == newItem | ||||
|     override fun areContentsTheSame( | ||||
|         oldItem: String, | ||||
|         newItem: String, | ||||
|     ) = oldItem == newItem | ||||
| } | ||||
|  |  | |||
|  | @ -6,16 +6,17 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX | |||
| import fr.free.nrw.commons.explore.media.PageableMediaFragment | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| class CategoriesMediaFragment : PageableMediaFragment() { | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var presenter: CategoryMediaPresenter | ||||
| 
 | ||||
|     override val injectedPresenter | ||||
|         get() = presenter | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|     override fun onViewCreated( | ||||
|         view: View, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") | ||||
|     } | ||||
|  |  | |||
|  | @ -13,8 +13,10 @@ interface CategoryMediaPresenter : PagingContract.Presenter<Media> | |||
| /** | ||||
|  * Presenter for DepictedImagesFragment | ||||
|  */ | ||||
| class CategoryMediaPresenterImpl @Inject constructor( | ||||
|     @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|     dataSourceFactory: PageableCategoriesMediaDataSource | ||||
| ) : BasePagingPresenter<Media>(mainThreadScheduler, dataSourceFactory), | ||||
|     CategoryMediaPresenter | ||||
| class CategoryMediaPresenterImpl | ||||
|     @Inject | ||||
|     constructor( | ||||
|         @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|         dataSourceFactory: PageableCategoriesMediaDataSource, | ||||
|     ) : BasePagingPresenter<Media>(mainThreadScheduler, dataSourceFactory), | ||||
|         CategoryMediaPresenter | ||||
|  |  | |||
|  | @ -7,14 +7,16 @@ import fr.free.nrw.commons.explore.paging.PageableBaseDataSource | |||
| import fr.free.nrw.commons.media.MediaClient | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class PageableCategoriesMediaDataSource @Inject constructor( | ||||
|     liveDataConverter: LiveDataConverter, | ||||
|     private val mediaClient: MediaClient | ||||
| ) : PageableBaseDataSource<Media>(liveDataConverter) { | ||||
|     override val loadFunction: LoadFunction<Media> = { loadSize: Int, startPosition: Int -> | ||||
|         if(startPosition == 0){ | ||||
|             mediaClient.resetCategoryContinuation(query) | ||||
| class PageableCategoriesMediaDataSource | ||||
|     @Inject | ||||
|     constructor( | ||||
|         liveDataConverter: LiveDataConverter, | ||||
|         private val mediaClient: MediaClient, | ||||
|     ) : PageableBaseDataSource<Media>(liveDataConverter) { | ||||
|         override val loadFunction: LoadFunction<Media> = { loadSize: Int, startPosition: Int -> | ||||
|             if (startPosition == 0) { | ||||
|                 mediaClient.resetCategoryContinuation(query) | ||||
|             } | ||||
|             mediaClient.getMediaListFromCategory(query).blockingGet() | ||||
|         } | ||||
|         mediaClient.getMediaListFromCategory(query).blockingGet() | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -5,15 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter | |||
| import fr.free.nrw.commons.explore.paging.PageableBaseDataSource | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class PageableParentCategoriesDataSource @Inject constructor( | ||||
|     liveDataConverter: LiveDataConverter, | ||||
|     val categoryClient: CategoryClient | ||||
| ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
| 
 | ||||
|     override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|         if (startPosition == 0) { | ||||
|             categoryClient.resetParentCategoryContinuation(query) | ||||
| class PageableParentCategoriesDataSource | ||||
|     @Inject | ||||
|     constructor( | ||||
|         liveDataConverter: LiveDataConverter, | ||||
|         val categoryClient: CategoryClient, | ||||
|     ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
|         override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|             if (startPosition == 0) { | ||||
|                 categoryClient.resetParentCategoryContinuation(query) | ||||
|             } | ||||
|             categoryClient.getParentCategoryList(query).blockingGet().map { it.name } | ||||
|         } | ||||
|         categoryClient.getParentCategoryList(query).blockingGet().map { it.name } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -7,9 +7,7 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX | |||
| import fr.free.nrw.commons.explore.categories.PageableCategoryFragment | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| class ParentCategoriesFragment : PageableCategoryFragment() { | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var presenter: ParentCategoriesPresenter | ||||
| 
 | ||||
|  | @ -18,9 +16,11 @@ class ParentCategoriesFragment : PageableCategoryFragment() { | |||
| 
 | ||||
|     override fun getEmptyText(query: String) = getString(R.string.no_parentcategory_found) | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|     override fun onViewCreated( | ||||
|         view: View, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,11 +7,12 @@ import io.reactivex.Scheduler | |||
| import javax.inject.Inject | ||||
| import javax.inject.Named | ||||
| 
 | ||||
| 
 | ||||
| interface ParentCategoriesPresenter : PagingContract.Presenter<String> | ||||
| 
 | ||||
| class ParentCategoriesPresenterImpl @Inject constructor( | ||||
|     @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|     dataSourceFactory: PageableParentCategoriesDataSource | ||||
| ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|     ParentCategoriesPresenter | ||||
| class ParentCategoriesPresenterImpl | ||||
|     @Inject | ||||
|     constructor( | ||||
|         @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|         dataSourceFactory: PageableParentCategoriesDataSource, | ||||
|     ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|         ParentCategoriesPresenter | ||||
|  |  | |||
|  | @ -5,13 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter | |||
| import fr.free.nrw.commons.explore.paging.PageableBaseDataSource | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class PageableSearchCategoriesDataSource @Inject constructor( | ||||
|     liveDataConverter: LiveDataConverter, | ||||
|     val categoryClient: CategoryClient | ||||
| ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
| 
 | ||||
|     override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|         categoryClient.searchCategories(query, loadSize, startPosition).blockingGet() | ||||
|             .map { it.name } | ||||
| class PageableSearchCategoriesDataSource | ||||
|     @Inject | ||||
|     constructor( | ||||
|         liveDataConverter: LiveDataConverter, | ||||
|         val categoryClient: CategoryClient, | ||||
|     ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
|         override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|             categoryClient | ||||
|                 .searchCategories(query, loadSize, startPosition) | ||||
|                 .blockingGet() | ||||
|                 .map { it.name } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -9,8 +9,10 @@ import javax.inject.Named | |||
| 
 | ||||
| interface SearchCategoriesFragmentPresenter : PagingContract.Presenter<String> | ||||
| 
 | ||||
| class SearchCategoriesFragmentPresenterImpl @Inject constructor( | ||||
|     @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|     dataSourceFactory: PageableSearchCategoriesDataSource | ||||
| ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|     SearchCategoriesFragmentPresenter | ||||
| class SearchCategoriesFragmentPresenterImpl | ||||
|     @Inject | ||||
|     constructor( | ||||
|         @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|         dataSourceFactory: PageableSearchCategoriesDataSource, | ||||
|     ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|         SearchCategoriesFragmentPresenter | ||||
|  |  | |||
|  | @ -5,15 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter | |||
| import fr.free.nrw.commons.explore.paging.PageableBaseDataSource | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| class PageableSubCategoriesDataSource @Inject constructor( | ||||
|     liveDataConverter: LiveDataConverter, | ||||
|     val categoryClient: CategoryClient | ||||
| ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
| 
 | ||||
|     override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|         if (startPosition == 0) { | ||||
|             categoryClient.resetSubCategoryContinuation(query) | ||||
| class PageableSubCategoriesDataSource | ||||
|     @Inject | ||||
|     constructor( | ||||
|         liveDataConverter: LiveDataConverter, | ||||
|         val categoryClient: CategoryClient, | ||||
|     ) : PageableBaseDataSource<String>(liveDataConverter) { | ||||
|         override val loadFunction = { loadSize: Int, startPosition: Int -> | ||||
|             if (startPosition == 0) { | ||||
|                 categoryClient.resetSubCategoryContinuation(query) | ||||
|             } | ||||
|             categoryClient.getSubCategoryList(query).blockingGet().map { it.name } | ||||
|         } | ||||
|         categoryClient.getSubCategoryList(query).blockingGet().map { it.name } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -7,9 +7,7 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX | |||
| import fr.free.nrw.commons.explore.categories.PageableCategoryFragment | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| class SubCategoriesFragment : PageableCategoryFragment() { | ||||
| 
 | ||||
|     @Inject lateinit var presenter: SubCategoriesPresenter | ||||
| 
 | ||||
|     override val injectedPresenter | ||||
|  | @ -17,7 +15,10 @@ class SubCategoriesFragment : PageableCategoryFragment() { | |||
| 
 | ||||
|     override fun getEmptyText(query: String) = getString(R.string.no_subcategory_found) | ||||
| 
 | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|     override fun onViewCreated( | ||||
|         view: View, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") | ||||
|     } | ||||
|  |  | |||
|  | @ -9,8 +9,10 @@ import javax.inject.Named | |||
| 
 | ||||
| interface SubCategoriesPresenter : PagingContract.Presenter<String> | ||||
| 
 | ||||
| class SubCategoriesPresenterImpl @Inject constructor( | ||||
|     @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|     dataSourceFactory: PageableSubCategoriesDataSource | ||||
| ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|     SubCategoriesPresenter | ||||
| class SubCategoriesPresenterImpl | ||||
|     @Inject | ||||
|     constructor( | ||||
|         @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, | ||||
|         dataSourceFactory: PageableSubCategoriesDataSource, | ||||
|     ) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), | ||||
|         SubCategoriesPresenter | ||||
|  |  | |||
|  | @ -9,22 +9,31 @@ import fr.free.nrw.commons.R | |||
| import fr.free.nrw.commons.databinding.ItemDepictionsBinding | ||||
| import fr.free.nrw.commons.upload.structure.depictions.DepictedItem | ||||
| 
 | ||||
| class DepictionAdapter(private val onDepictionClicked: (DepictedItem) -> Unit) : | ||||
|     PagedListAdapter<DepictedItem, DepictedItemViewHolder>(DepictionDiffUtilCallback) { | ||||
| 
 | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = DepictedItemViewHolder( | ||||
|         ItemDepictionsBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
| class DepictionAdapter( | ||||
|     private val onDepictionClicked: (DepictedItem) -> Unit, | ||||
| ) : PagedListAdapter<DepictedItem, DepictedItemViewHolder>(DepictionDiffUtilCallback) { | ||||
|     override fun onCreateViewHolder( | ||||
|         parent: ViewGroup, | ||||
|         viewType: Int, | ||||
|     ) = DepictedItemViewHolder( | ||||
|         ItemDepictionsBinding.inflate(LayoutInflater.from(parent.context), parent, false), | ||||
|     ) | ||||
| 
 | ||||
|     override fun onBindViewHolder(holder: DepictedItemViewHolder, position: Int) { | ||||
|     override fun onBindViewHolder( | ||||
|         holder: DepictedItemViewHolder, | ||||
|         position: Int, | ||||
|     ) { | ||||
|         holder.bind(getItem(position)!!, onDepictionClicked) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class DepictedItemViewHolder( | ||||
|     private val binding: ItemDepictionsBinding | ||||
|     private val binding: ItemDepictionsBinding, | ||||
| ) : RecyclerView.ViewHolder(binding.root) { | ||||
|     fun bind(item: DepictedItem, onDepictionClicked: (DepictedItem) -> Unit) = with(binding) { | ||||
|     fun bind( | ||||
|         item: DepictedItem, | ||||
|         onDepictionClicked: (DepictedItem) -> Unit, | ||||
|     ) = with(binding) { | ||||
|         root.setOnClickListener { onDepictionClicked(item) } | ||||
|         depictsLabel.text = item.name | ||||
|         description.text = item.description | ||||
|  | @ -37,9 +46,13 @@ class DepictedItemViewHolder( | |||
| } | ||||
| 
 | ||||
| private object DepictionDiffUtilCallback : DiffUtil.ItemCallback<DepictedItem>() { | ||||
|     override fun areItemsTheSame(oldItem: DepictedItem, newItem: DepictedItem) = | ||||
|         oldItem.id == newItem.id | ||||
|     override fun areItemsTheSame( | ||||
|         oldItem: DepictedItem, | ||||
|         newItem: DepictedItem, | ||||
|     ) = oldItem.id == newItem.id | ||||
| 
 | ||||
|     override fun areContentsTheSame(oldItem: DepictedItem, newItem: DepictedItem) = | ||||
|         oldItem == newItem | ||||
|     override fun areContentsTheSame( | ||||
|         oldItem: DepictedItem, | ||||
|         newItem: DepictedItem, | ||||
|     ) = oldItem == newItem | ||||
| } | ||||
|  |  | |||
|  | @ -14,16 +14,12 @@ import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsPresenterIm | |||
|  */ | ||||
| @Module | ||||
| abstract class DepictionModule { | ||||
|     @Binds | ||||
|     abstract fun ParentDepictionsPresenterImpl.bindsParentDepictionPresenter(): ParentDepictionsPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun ParentDepictionsPresenterImpl.bindsParentDepictionPresenter() | ||||
|             : ParentDepictionsPresenter | ||||
|     abstract fun ChildDepictionsPresenterImpl.bindsChildDepictionPresenter(): ChildDepictionsPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun ChildDepictionsPresenterImpl.bindsChildDepictionPresenter() | ||||
|             : ChildDepictionsPresenter | ||||
| 
 | ||||
|     @Binds | ||||
|     abstract fun DepictedImagesPresenterImpl.bindsDepictedImagesContractPresenter() | ||||
|             : DepictedImagesPresenter | ||||
|     abstract fun DepictedImagesPresenterImpl.bindsDepictedImagesContractPresenter(): DepictedImagesPresenter | ||||
| } | ||||
|  |  | |||
|  | @ -20,89 +20,101 @@ import javax.inject.Singleton | |||
|  * Depicts Client to handle custom calls to Commons Wikibase APIs | ||||
|  */ | ||||
| @Singleton | ||||
| class DepictsClient @Inject constructor(private val depictsInterface: DepictsInterface) { | ||||
| 
 | ||||
|     /** | ||||
|      * Search for depictions using the search item | ||||
|      * @return list of depicted items | ||||
|      */ | ||||
|     fun searchForDepictions(query: String?, limit: Int, offset: Int): Single<List<DepictedItem>> { | ||||
|         val language = Locale.getDefault().language | ||||
|         return depictsInterface.searchForDepicts(query, "$limit", language, language, "$offset") | ||||
|             .map { it.search.joinToString("|", transform = DepictSearchItem::id) } | ||||
|             .mapToDepictions() | ||||
|     } | ||||
| 
 | ||||
|     fun getEntities(ids: String): Single<Entities> { | ||||
|         return depictsInterface.getEntities(ids) | ||||
|     } | ||||
| 
 | ||||
|     fun toDepictions(sparqlResponse: Single<SparqlResponse>): Single<List<DepictedItem>> { | ||||
|         return sparqlResponse.map { | ||||
|             it.results.bindings.joinToString("|", transform = Binding::id) | ||||
|         }.mapToDepictions() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches Entities from ids ex. "Q1233|Q546" and converts them into DepictedItem | ||||
|      */ | ||||
|     @SuppressLint("CheckResult") | ||||
|     private fun Single<String>.mapToDepictions() = | ||||
|         flatMap(::getEntities) | ||||
|         .map { entities -> | ||||
|             entities.entities().values.map { entity -> | ||||
|                 mapToDepictItem(entity) | ||||
|             } | ||||
| class DepictsClient | ||||
|     @Inject | ||||
|     constructor( | ||||
|         private val depictsInterface: DepictsInterface, | ||||
|     ) { | ||||
|         /** | ||||
|          * Search for depictions using the search item | ||||
|          * @return list of depicted items | ||||
|          */ | ||||
|         fun searchForDepictions( | ||||
|             query: String?, | ||||
|             limit: Int, | ||||
|             offset: Int, | ||||
|         ): Single<List<DepictedItem>> { | ||||
|             val language = Locale.getDefault().language | ||||
|             return depictsInterface | ||||
|                 .searchForDepicts(query, "$limit", language, language, "$offset") | ||||
|                 .map { it.search.joinToString("|", transform = DepictSearchItem::id) } | ||||
|                 .mapToDepictions() | ||||
|         } | ||||
| 
 | ||||
|     /** | ||||
|      * Convert different entities into DepictedItem | ||||
|      */ | ||||
|     private fun mapToDepictItem(entity: Entities.Entity): DepictedItem { | ||||
|         return if (entity.descriptions().byLanguageOrFirstOrEmpty() == "") { | ||||
|             val instanceOfIDs = entity[WikidataProperties.INSTANCE_OF] | ||||
|                 .toIds() | ||||
|             if (instanceOfIDs.isNotEmpty()) { | ||||
|                 val entities: Entities = getEntities(instanceOfIDs[0]).blockingGet() | ||||
|                 val nameAsDescription = entities.entities().values.first().labels() | ||||
|                     .byLanguageOrFirstOrEmpty() | ||||
|                 DepictedItem( | ||||
|                     entity, | ||||
|                     entity.labels().byLanguageOrFirstOrEmpty(), | ||||
|                     nameAsDescription | ||||
|                 ) | ||||
|         fun getEntities(ids: String): Single<Entities> = depictsInterface.getEntities(ids) | ||||
| 
 | ||||
|         fun toDepictions(sparqlResponse: Single<SparqlResponse>): Single<List<DepictedItem>> = | ||||
|             sparqlResponse | ||||
|                 .map { | ||||
|                     it.results.bindings.joinToString("|", transform = Binding::id) | ||||
|                 }.mapToDepictions() | ||||
| 
 | ||||
|         /** | ||||
|          * Fetches Entities from ids ex. "Q1233|Q546" and converts them into DepictedItem | ||||
|          */ | ||||
|         @SuppressLint("CheckResult") | ||||
|         private fun Single<String>.mapToDepictions() = | ||||
|             flatMap(::getEntities) | ||||
|                 .map { entities -> | ||||
|                     entities.entities().values.map { entity -> | ||||
|                         mapToDepictItem(entity) | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|         /** | ||||
|          * Convert different entities into DepictedItem | ||||
|          */ | ||||
|         private fun mapToDepictItem(entity: Entities.Entity): DepictedItem = | ||||
|             if (entity.descriptions().byLanguageOrFirstOrEmpty() == "") { | ||||
|                 val instanceOfIDs = | ||||
|                     entity[WikidataProperties.INSTANCE_OF] | ||||
|                         .toIds() | ||||
|                 if (instanceOfIDs.isNotEmpty()) { | ||||
|                     val entities: Entities = getEntities(instanceOfIDs[0]).blockingGet() | ||||
|                     val nameAsDescription = | ||||
|                         entities | ||||
|                             .entities() | ||||
|                             .values | ||||
|                             .first() | ||||
|                             .labels() | ||||
|                             .byLanguageOrFirstOrEmpty() | ||||
|                     DepictedItem( | ||||
|                         entity, | ||||
|                         entity.labels().byLanguageOrFirstOrEmpty(), | ||||
|                         nameAsDescription, | ||||
|                     ) | ||||
|                 } else { | ||||
|                     DepictedItem( | ||||
|                         entity, | ||||
|                         entity.labels().byLanguageOrFirstOrEmpty(), | ||||
|                         "", | ||||
|                     ) | ||||
|                 } | ||||
|             } else { | ||||
|                 DepictedItem( | ||||
|                     entity, | ||||
|                     entity.labels().byLanguageOrFirstOrEmpty(), | ||||
|                     "" | ||||
|                     entity.descriptions().byLanguageOrFirstOrEmpty(), | ||||
|                 ) | ||||
|             } | ||||
|         } else { | ||||
|             DepictedItem( | ||||
|                 entity, | ||||
|                 entity.labels().byLanguageOrFirstOrEmpty(), | ||||
|                 entity.descriptions().byLanguageOrFirstOrEmpty() | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Tries to get Entities.Label by default language from the map. | ||||
|      * If that returns null, Tries to retrieve first element from the map. | ||||
|      * If that still returns null, function returns "". | ||||
|      */ | ||||
|     private fun Map<String, Entities.Label>.byLanguageOrFirstOrEmpty() = | ||||
|         let { | ||||
|             it[Locale.getDefault().language] ?: it.values.firstOrNull() }?.value() ?: "" | ||||
|         /** | ||||
|          * Tries to get Entities.Label by default language from the map. | ||||
|          * If that returns null, Tries to retrieve first element from the map. | ||||
|          * If that still returns null, function returns "". | ||||
|          */ | ||||
|         private fun Map<String, Entities.Label>.byLanguageOrFirstOrEmpty() = | ||||
|             let { | ||||
|                 it[Locale.getDefault().language] ?: it.values.firstOrNull() | ||||
|             }?.value() ?: "" | ||||
| 
 | ||||
|     /** | ||||
|      * returns list of id ex. "Q2323" from Statement_partial | ||||
|      */ | ||||
|     private fun List<Statement_partial>?.toIds(): List<String> { | ||||
|         return this?.map { it.mainSnak.dataValue } | ||||
|             ?.filterIsInstance<DataValue.EntityId>() | ||||
|             ?.map { it.value.id } | ||||
|             ?: emptyList() | ||||
|         /** | ||||
|          * returns list of id ex. "Q2323" from Statement_partial | ||||
|          */ | ||||
|         private fun List<Statement_partial>?.toIds(): List<String> = | ||||
|             this | ||||
|                 ?.map { it.mainSnak.dataValue } | ||||
|                 ?.filterIsInstance<DataValue.EntityId>() | ||||
|                 ?.map { it.value.id } | ||||
|                 ?: emptyList() | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import fr.free.nrw.commons.R | |||
| import fr.free.nrw.commons.explore.paging.BasePagingFragment | ||||
| import fr.free.nrw.commons.upload.structure.depictions.DepictedItem | ||||
| 
 | ||||
| 
 | ||||
| abstract class PageableDepictionsFragment : BasePagingFragment<DepictedItem>() { | ||||
|     override val errorTextId: Int = R.string.error_loading_depictions | ||||
|     override val pagedListAdapter by lazy { | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 tristan81
						tristan81