Issue-5662-kotlinstyle (#5833)

* *.kt: bulk correction of formatting using ktlint --format

* *.kt: replace wildcard imports and second stage auto format ktlint --format

* QuizQuestionTest.kt: modified property names to camel case to meet ktlint standard

* LevelControllerTest.kt: modified property names to camel case to meet ktlint standard

* QuizActivityUnitTest.kt: modified property names to camel case to meet ktlint standard

* MediaDetailFragmentUnitTests.kt: modified property names to camel case to meet ktlint standard

* UploadWorker.kt: modified property names to camel case to meet ktlint standard

* UploadClient.kt: modified property names to camel case to meet ktlint standard

* BasePagingPresenter.kt: modified property names to camel case to meet ktlint standard

* DescriptionEditActivity.kt: modified property names to camel case to meet ktlint standard

* OnSwipeTouchListener.kt: modified property names to camel case to meet ktlint standard

* MediaDetailFragmentUnitTests.kt: corrected excessive line length to meet ktlint standard

* DepictedItem.kt: corrected property name format and catch format to for  ktlint standard

* UploadCategoryAdapter.kt: corrected class definition format to meet ktlint standard

* CustomSelectorActivity.kt: reformatted function names to first letter lowercase to meet ktlint standard

* MediaDetailFragmentUnitTests.kt: fix string literal indentation to meet ktlint standard

* NotForUploadDao.kt: file renamed to match class name, new file NotForUploadStatusDao.kt

* UploadedDao.kt: file renamed to match class name, new file UploadedStatusDao.kt

* Urls.kt: fixed excessive line length for ktLint standard

* Snak_partial.kt & Statement_partial.kt: refactored to remove underscores in class names to meet ktLint standard

* *.kt: fixed consecutive KDOC error for ktLint

* PageableBaseDataSourceTest.kt & UploadPresenterTest.kt: fixed excessive line lengths to meet ktLint standard

* CheckboxTriStatesTest.kt: renamed file to match class name to meet ktLint standard

* .kt: resolved backing-property-naming error in ktLint, made matching properties public, matched names and refactored

* TestConnectionFactory.kt: fixed property naming to adhere to ktLint standard
This commit is contained in:
tristan 2024-09-19 14:56:45 +10:00 committed by GitHub
parent 950539c55c
commit 2d82a430c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
405 changed files with 11032 additions and 9137 deletions

View file

@ -25,7 +25,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AboutActivityTest { class AboutActivityTest {
@get:Rule @get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java) var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
@ -36,7 +35,8 @@ class AboutActivityTest {
device.setOrientationNatural() device.setOrientationNatural()
device.freezeRotation() device.freezeRotation()
Intents.init() Intents.init()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
} }
@ -47,11 +47,12 @@ class AboutActivityTest {
@Test @Test
fun testBuildNumber() { fun testBuildNumber() {
Espresso.onView(ViewMatchers.withId(R.id.about_version)) Espresso
.onView(ViewMatchers.withId(R.id.about_version))
.check( .check(
ViewAssertions.matches( ViewAssertions.matches(
withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()) withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()),
) ),
) )
} }
@ -61,8 +62,8 @@ class AboutActivityTest {
Intents.intended( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.WEBSITE_URL) IntentMatchers.hasData(Urls.WEBSITE_URL),
) ),
) )
} }
@ -73,8 +74,8 @@ class AboutActivityTest {
CoreMatchers.anyOf( CoreMatchers.anyOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL), 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( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), 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( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), 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( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), 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( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.CREDITS_URL) IntentMatchers.hasData(Urls.CREDITS_URL),
) ),
) )
} }
@Test @Test
fun testLaunchUserGuide() { fun testLaunchUserGuide() {
Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click())
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW), Intents.intended(
IntentMatchers.hasData(Urls.USER_GUIDE_URL))) CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.USER_GUIDE_URL),
),
)
} }
@Test @Test
fun testLaunchAboutFaq() { fun testLaunchAboutFaq() {
Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click())
Intents.intended( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FAQ_URL) IntentMatchers.hasData(Urls.FAQ_URL),
) ),
) )
} }
} }

View file

@ -18,12 +18,14 @@ import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SignupActivity import fr.free.nrw.commons.auth.SignupActivity
import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.not import org.hamcrest.CoreMatchers.not
import org.junit.* import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LoginActivityTest { class LoginActivityTest {
@get:Rule @get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java) var activityRule = ActivityTestRule(LoginActivity::class.java)
@ -49,8 +51,8 @@ class LoginActivityTest {
Intents.intended( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL) IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL),
) ),
) )
} }
@ -64,4 +66,4 @@ class LoginActivityTest {
fun orientationChange() { fun orientationChange() {
UITestHelper.changeOrientation(activityRule) UITestHelper.changeOrientation(activityRule)
} }
} }

View file

@ -21,20 +21,23 @@ import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.notification.NotificationActivity import fr.free.nrw.commons.notification.NotificationActivity
import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.junit.* import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@LargeTest @LargeTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MainActivityTest { class MainActivityTest {
@get:Rule @get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
@get:Rule @get:Rule
var mGrantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( var mGrantPermissionRule: GrantPermissionRule =
"android.permission.ACCESS_FINE_LOCATION" GrantPermissionRule.grant(
) "android.permission.ACCESS_FINE_LOCATION",
)
private val device: UiDevice = private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@ -48,7 +51,8 @@ class MainActivityTest {
UITestHelper.loginUser() UITestHelper.loginUser()
UITestHelper.skipWelcome() UITestHelper.skipWelcome()
Intents.init() Intents.init()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
val storeName = context.packageName + "_preferences" val storeName = context.packageName + "_preferences"
@ -62,137 +66,149 @@ class MainActivityTest {
@Test @Test
fun testNearby() { fun testNearby() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
childAtPosition( Matchers.allOf(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), childAtPosition(
0 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
1,
), ),
1 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed() ).perform(ViewActions.click())
) Espresso
).perform(ViewActions.click()) .onView(ViewMatchers.withId(R.id.fragmentContainer))
Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed())) .check(matches(ViewMatchers.isDisplayed()))
UITestHelper.sleep(10000) UITestHelper.sleep(10000)
val actionMenuItemView2 = Espresso.onView( val actionMenuItemView2 =
Matchers.allOf( Espresso.onView(
ViewMatchers.withId(R.id.list_sheet), ViewMatchers.withContentDescription("List"), Matchers.allOf(
childAtPosition( ViewMatchers.withId(R.id.list_sheet),
ViewMatchers.withContentDescription("List"),
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.toolbar), childAtPosition(
1 ViewMatchers.withId(R.id.toolbar),
1,
),
0,
), ),
0 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed()
) )
)
actionMenuItemView2.perform(ViewActions.click()) actionMenuItemView2.perform(ViewActions.click())
UITestHelper.sleep(1000) UITestHelper.sleep(1000)
} }
@Test @Test
fun testExplore() { fun testExplore() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
childAtPosition( Matchers.allOf(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), childAtPosition(
0 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
2,
), ),
2 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed() ).perform(ViewActions.click())
) Espresso
).perform(ViewActions.click()) .onView(ViewMatchers.withId(R.id.fragmentContainer))
Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed())) .check(matches(ViewMatchers.isDisplayed()))
UITestHelper.sleep(1000) UITestHelper.sleep(1000)
} }
@Test @Test
fun testContributions() { fun testContributions() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
childAtPosition( Matchers.allOf(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), childAtPosition(
0 ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0,
),
0,
), ),
0 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed() ).perform(ViewActions.click())
) Espresso
).perform(ViewActions.click()) .onView(ViewMatchers.withId(R.id.fragmentContainer))
Espresso.onView(ViewMatchers.withId(R.id.fragmentContainer))
.check(matches(ViewMatchers.isDisplayed())) .check(matches(ViewMatchers.isDisplayed()))
Espresso.onView( Espresso
Matchers.allOf( .onView(
ViewMatchers.withId(R.id.contributionImage), Matchers.allOf(
childAtPosition( ViewMatchers.withId(R.id.contributionImage),
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.contributionsList), childAtPosition(
0 ViewMatchers.withId(R.id.contributionsList),
0,
),
1,
), ),
1 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed() ).perform(ViewActions.click())
) val actionMenuItemView =
).perform(ViewActions.click()) Espresso.onView(
val actionMenuItemView = Espresso.onView( Matchers.allOf(
Matchers.allOf( ViewMatchers.withId(R.id.menu_bookmark_current_image),
ViewMatchers.withId(R.id.menu_bookmark_current_image),
childAtPosition(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.toolbar), childAtPosition(
1 ViewMatchers.withId(R.id.toolbar),
1,
),
0,
), ),
0 ViewMatchers.isDisplayed(),
), ),
ViewMatchers.isDisplayed()
) )
)
actionMenuItemView.perform(ViewActions.click()) actionMenuItemView.perform(ViewActions.click())
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
} }
@Test @Test
fun testBookmarks() { fun testBookmarks() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
childAtPosition( Matchers.allOf(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), childAtPosition(
0 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) UITestHelper.sleep(1000)
} }
@Test @Test
fun testNotifications() { fun testNotifications() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
ViewMatchers.withId(R.id.notifications), Matchers.allOf(
childAtPosition( ViewMatchers.withId(R.id.notifications),
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.toolbar), childAtPosition(
1 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)) Intents.intended(IntentMatchers.hasComponent(NotificationActivity::class.java.name))
Espresso.pressBack() Espresso.pressBack()
UITestHelper.sleep(1000) UITestHelper.sleep(1000)
} }
} }

View file

@ -4,7 +4,6 @@ import android.app.Activity
import android.app.Instrumentation import android.app.Instrumentation
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.swipeRight
import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
@ -26,7 +25,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ProfileActivityTest { class ProfileActivityTest {
@get:Rule @get:Rule
var activityRule = IntentsTestRule(LoginActivity::class.java) var activityRule = IntentsTestRule(LoginActivity::class.java)
@ -38,7 +36,8 @@ class ProfileActivityTest {
device.freezeRotation() device.freezeRotation()
UITestHelper.loginUser() UITestHelper.loginUser()
UITestHelper.skipWelcome() UITestHelper.skipWelcome()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
} }
@ -50,20 +49,19 @@ class ProfileActivityTest {
childAtPosition( childAtPosition(
childAtPosition( childAtPosition(
withId(R.id.fragment_main_nav_tab_layout), withId(R.id.fragment_main_nav_tab_layout),
0 0,
), ),
4 4,
), ),
ViewMatchers.isDisplayed() ViewMatchers.isDisplayed(),
) ),
).perform(ViewActions.click()) ).perform(ViewActions.click())
onView(Matchers.allOf(withId(R.id.more_profile))).perform( onView(Matchers.allOf(withId(R.id.more_profile))).perform(
ViewActions.scrollTo(), ViewActions.scrollTo(),
ViewActions.click() ViewActions.click(),
) )
device.swipe(1033,1346,531,1346,20) device.swipe(1033, 1346, 531, 1346, 20)
UITestHelper.sleep(5000) UITestHelper.sleep(5000)
Intents.intended(hasComponent(ProfileActivity::class.java.name)) Intents.intended(hasComponent(ProfileActivity::class.java.name))
} }
} }

View file

@ -9,7 +9,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ReviewActivityTest { class ReviewActivityTest {
@get:Rule @get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java) var activityRule: ActivityTestRule<*> = ActivityTestRule(ReviewActivity::class.java)
@ -17,5 +16,4 @@ class ReviewActivityTest {
fun orientationChange() { fun orientationChange() {
UITestHelper.changeOrientation(activityRule) UITestHelper.changeOrientation(activityRule)
} }
}
}

View file

@ -16,7 +16,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class SearchActivityTest { class SearchActivityTest {
@get:Rule @get:Rule
var activityRule = ActivityTestRule(SearchActivity::class.java) var activityRule = ActivityTestRule(SearchActivity::class.java)
@ -31,21 +30,22 @@ class SearchActivityTest {
@Test @Test
fun exploreActivityTest() { fun exploreActivityTest() {
val searchAutoComplete = Espresso.onView( val searchAutoComplete =
Matchers.allOf( Espresso.onView(
UITestHelper.childAtPosition( Matchers.allOf(
Matchers.allOf( UITestHelper.childAtPosition(
ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), Matchers.allOf(
UITestHelper.childAtPosition(
ViewMatchers.withClassName(Matchers.`is`("android.widget.LinearLayout")), 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()) searchAutoComplete.perform(ViewActions.replaceText("cat"), ViewActions.closeSoftKeyboard())
UITestHelper.sleep(5000) UITestHelper.sleep(5000)
device.swipe(1000, 1400, 500, 1400, 20) device.swipe(1000, 1400, 500, 1400, 20)
@ -56,4 +56,4 @@ class SearchActivityTest {
device.swipe(800, 1400, 600, 1400, 20) device.swipe(800, 1400, 600, 1400, 20)
UITestHelper.sleep(1000) UITestHelper.sleep(1000)
} }
} }

View file

@ -22,7 +22,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class SettingsActivityLoggedInTest { class SettingsActivityLoggedInTest {
@get:Rule @get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java) var activityRule: ActivityTestRule<*> = ActivityTestRule(LoginActivity::class.java)
@ -35,31 +34,32 @@ class SettingsActivityLoggedInTest {
device.freezeRotation() device.freezeRotation()
UITestHelper.loginUser() UITestHelper.loginUser()
UITestHelper.skipWelcome() UITestHelper.skipWelcome()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
} }
@Test @Test
fun testSettings() { fun testSettings() {
Espresso.onView( Espresso
Matchers.allOf( .onView(
ViewMatchers.withContentDescription("More"), Matchers.allOf(
UITestHelper.childAtPosition( ViewMatchers.withContentDescription("More"),
UITestHelper.childAtPosition( UITestHelper.childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), UITestHelper.childAtPosition(
0 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( Espresso.onView(Matchers.allOf(ViewMatchers.withId(R.id.more_settings))).perform(
ViewActions.scrollTo(), ViewActions.scrollTo(),
ViewActions.click() ViewActions.click(),
) )
Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name)) Intents.intended(IntentMatchers.hasComponent(SettingsActivity::class.java.name))
UITestHelper.sleep(1000) UITestHelper.sleep(1000)
} }
}
}

View file

@ -23,7 +23,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class SettingsActivityTest { class SettingsActivityTest {
private lateinit var defaultKvStore: JsonKvStore private lateinit var defaultKvStore: JsonKvStore
@get:Rule @get:Rule
@ -44,22 +43,24 @@ class SettingsActivityTest {
fun useAuthorNameTogglesOn() { fun useAuthorNameTogglesOn() {
// Turn on "Use author name" preference if currently off // Turn on "Use author name" preference if currently off
if (!defaultKvStore.getBoolean("useAuthorName", false)) { if (!defaultKvStore.getBoolean("useAuthorName", false)) {
Espresso.onView( Espresso
allOf( .onView(
withId(R.id.recycler_view), allOf(
childAtPosition(withId(android.R.id.list_container), 0) 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 // Check authorName preference is enabled
Espresso.onView( Espresso
allOf( .onView(
withId(R.id.recycler_view), allOf(
childAtPosition(withId(android.R.id.list_container), 0) withId(R.id.recycler_view),
) childAtPosition(withId(android.R.id.list_container), 0),
).check(matches(isEnabled())) ),
).check(matches(isEnabled()))
} }
@Test @Test

View file

@ -10,17 +10,20 @@ import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.rule.ActivityTestRule import androidx.test.rule.ActivityTestRule
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import org.hamcrest.* import org.hamcrest.BaseMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.TypeSafeMatcher
import timber.log.Timber import timber.log.Timber
class UITestHelper { class UITestHelper {
companion object { companion object {
fun skipWelcome() { fun skipWelcome() {
try { try {
onView(ViewMatchers.withId(R.id.button_ok)) onView(ViewMatchers.withId(R.id.button_ok))
.perform(ViewActions.click()) .perform(ViewActions.click())
//Skip tutorial // Skip tutorial
onView(ViewMatchers.withId(R.id.finishTutorialButton)) onView(ViewMatchers.withId(R.id.finishTutorialButton))
.perform(ViewActions.click()) .perform(ViewActions.click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
@ -29,27 +32,31 @@ class UITestHelper {
fun skipLogin() { fun skipLogin() {
try { try {
//Skip Login // Skip Login
val htmlTextView = onView( val htmlTextView =
Matchers.allOf( onView(
ViewMatchers.withId(R.id.skip_login), ViewMatchers.withText("Skip"), Matchers.allOf(
ViewMatchers.isDisplayed() ViewMatchers.withId(R.id.skip_login),
ViewMatchers.withText("Skip"),
ViewMatchers.isDisplayed(),
),
) )
)
htmlTextView.perform(ViewActions.click()) htmlTextView.perform(ViewActions.click())
val appCompatButton = onView( val appCompatButton =
Matchers.allOf( onView(
ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), Matchers.allOf(
childAtPosition( ViewMatchers.withId(android.R.id.button1),
ViewMatchers.withText("Yes"),
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.buttonPanel), childAtPosition(
0 ViewMatchers.withId(R.id.buttonPanel),
0,
),
3,
), ),
3 ),
)
) )
)
appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click()) appCompatButton.perform(ViewActions.scrollTo(), ViewActions.click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
@ -57,18 +64,18 @@ class UITestHelper {
fun loginUser() { fun loginUser() {
try { try {
//Perform Login // Perform Login
sleep(3000) sleep(3000)
onView(ViewMatchers.withId(R.id.login_username)) onView(ViewMatchers.withId(R.id.login_username))
.perform( .perform(
ViewActions.replaceText(getTestUsername()), ViewActions.replaceText(getTestUsername()),
ViewActions.closeSoftKeyboard() ViewActions.closeSoftKeyboard(),
) )
sleep(2000) sleep(2000)
onView(ViewMatchers.withId(R.id.login_password)) onView(ViewMatchers.withId(R.id.login_password))
.perform( .perform(
ViewActions.replaceText(getTestUserPassword()), ViewActions.replaceText(getTestUserPassword()),
ViewActions.closeSoftKeyboard() ViewActions.closeSoftKeyboard(),
) )
sleep(2000) sleep(2000)
onView(ViewMatchers.withId(R.id.login_button)) onView(ViewMatchers.withId(R.id.login_button))
@ -76,7 +83,6 @@ class UITestHelper {
sleep(10000) sleep(10000)
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
} }
fun logoutUser() { fun logoutUser() {
@ -87,36 +93,38 @@ class UITestHelper {
childAtPosition( childAtPosition(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.fragment_main_nav_tab_layout), ViewMatchers.withId(R.id.fragment_main_nav_tab_layout),
0 0,
), ),
4 4,
), ),
ViewMatchers.isDisplayed() ViewMatchers.isDisplayed(),
) ),
).perform(ViewActions.click()) ).perform(ViewActions.click())
onView( onView(
Matchers.allOf( Matchers.allOf(
ViewMatchers.withId(R.id.more_logout), ViewMatchers.withText("Logout"), ViewMatchers.withId(R.id.more_logout),
ViewMatchers.withText("Logout"),
childAtPosition( childAtPosition(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet), ViewMatchers.withId(R.id.scroll_view_more_bottom_sheet),
0 0,
), ),
6 6,
) ),
) ),
).perform(ViewActions.scrollTo(), ViewActions.click()) ).perform(ViewActions.scrollTo(), ViewActions.click())
onView( onView(
Matchers.allOf( Matchers.allOf(
ViewMatchers.withId(android.R.id.button1), ViewMatchers.withText("Yes"), ViewMatchers.withId(android.R.id.button1),
ViewMatchers.withText("Yes"),
childAtPosition( childAtPosition(
childAtPosition( childAtPosition(
ViewMatchers.withId(R.id.buttonPanel), ViewMatchers.withId(R.id.buttonPanel),
0 0,
), ),
3 3,
) ),
) ),
).perform(ViewActions.scrollTo(), ViewActions.click()) ).perform(ViewActions.scrollTo(), ViewActions.click())
sleep(5000) sleep(5000)
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
@ -124,9 +132,9 @@ class UITestHelper {
} }
fun childAtPosition( fun childAtPosition(
parentMatcher: Matcher<View>, position: Int parentMatcher: Matcher<View>,
position: Int,
): Matcher<View> { ): Matcher<View> {
return object : TypeSafeMatcher<View>() { return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) { override fun describeTo(description: Description) {
description.appendText("Child at position $position in parent ") description.appendText("Child at position $position in parent ")
@ -135,8 +143,9 @@ class UITestHelper {
public override fun matchesSafely(view: View): Boolean { public override fun matchesSafely(view: View): Boolean {
val parent = view.parent val parent = view.parent
return parent is ViewGroup && parentMatcher.matches(parent) return parent is ViewGroup &&
&& view == parent.getChildAt(position) parentMatcher.matches(parent) &&
view == parent.getChildAt(position)
} }
} }
} }
@ -154,14 +163,18 @@ class UITestHelper {
val username = BuildConfig.TEST_USERNAME val username = BuildConfig.TEST_USERNAME
if (StringUtils.isEmpty(username) || username == "null") { if (StringUtils.isEmpty(username) || username == "null") {
throw NotImplementedError("Configure your beta account's username") throw NotImplementedError("Configure your beta account's username")
} else return username } else {
return username
}
} }
private fun getTestUserPassword(): String { private fun getTestUserPassword(): String {
val password = BuildConfig.TEST_PASSWORD val password = BuildConfig.TEST_PASSWORD
if (StringUtils.isEmpty(password) || password == "null") { if (StringUtils.isEmpty(password) || password == "null") {
throw NotImplementedError("Configure your beta account's password") throw NotImplementedError("Configure your beta account's password")
} else return password } else {
return password
}
} }
fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) { fun <T : Activity> changeOrientation(activityRule: ActivityTestRule<T>) {
@ -174,6 +187,7 @@ class UITestHelper {
fun <T> first(matcher: Matcher<T>): Matcher<T>? { fun <T> first(matcher: Matcher<T>): Matcher<T>? {
return object : BaseMatcher<T>() { return object : BaseMatcher<T>() {
var isFirst = true var isFirst = true
override fun matches(item: Any): Boolean { override fun matches(item: Any): Boolean {
if (isFirst && matcher.matches(item)) { if (isFirst && matcher.matches(item)) {
isFirst = false isFirst = false
@ -188,4 +202,4 @@ class UITestHelper {
} }
} }
} }
} }

View file

@ -4,7 +4,10 @@ import android.app.Activity
import android.app.Instrumentation import android.app.Instrumentation
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.matcher.IntentMatchers
@ -28,7 +31,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class UploadCancelledTest { class UploadCancelledTest {
@Rule @Rule
@JvmField @JvmField
var mActivityTestRule = ActivityTestRule(LoginActivity::class.java) var mActivityTestRule = ActivityTestRule(LoginActivity::class.java)
@ -37,7 +39,7 @@ class UploadCancelledTest {
@JvmField @JvmField
var mGrantPermissionRule: GrantPermissionRule = var mGrantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant( GrantPermissionRule.grant(
"android.permission.WRITE_EXTERNAL_STORAGE" "android.permission.WRITE_EXTERNAL_STORAGE",
) )
private val device: UiDevice = private val device: UiDevice =
@ -48,14 +50,14 @@ class UploadCancelledTest {
try { try {
Intents.init() Intents.init()
} catch (ex: IllegalStateException) { } catch (ex: IllegalStateException) {
} }
device.unfreezeRotation() device.unfreezeRotation()
device.setOrientationNatural() device.setOrientationNatural()
device.freezeRotation() device.freezeRotation()
UITestHelper.loginUser() UITestHelper.loginUser()
UITestHelper.skipWelcome() UITestHelper.skipWelcome()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal())) Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
} }
@ -64,130 +66,137 @@ class UploadCancelledTest {
try { try {
Intents.release() Intents.release()
} catch (ex: IllegalStateException) { } catch (ex: IllegalStateException) {
} }
} }
@Test @Test
fun uploadCancelledAfterLocationPickedTest() { fun uploadCancelledAfterLocationPickedTest() {
val bottomNavigationItemView =
val bottomNavigationItemView = onView( onView(
allOf( allOf(
childAtPosition(
childAtPosition( childAtPosition(
withId(R.id.fragment_main_nav_tab_layout), childAtPosition(
0 withId(R.id.fragment_main_nav_tab_layout),
0,
),
1,
), ),
1 isDisplayed(),
), ),
isDisplayed()
) )
)
bottomNavigationItemView.perform(click()) bottomNavigationItemView.perform(click())
UITestHelper.sleep(12000) UITestHelper.sleep(12000)
val actionMenuItemView = onView( val actionMenuItemView =
allOf( onView(
withId(R.id.list_sheet), allOf(
childAtPosition( withId(R.id.list_sheet),
childAtPosition( childAtPosition(
withId(R.id.toolbar), childAtPosition(
1 withId(R.id.toolbar),
1,
),
0,
), ),
0 isDisplayed(),
), ),
isDisplayed()
) )
)
actionMenuItemView.perform(click()) actionMenuItemView.perform(click())
val recyclerView = onView( val recyclerView =
allOf( onView(
withId(R.id.rv_nearby_list), allOf(
withId(R.id.rv_nearby_list),
),
) )
)
recyclerView.perform( recyclerView.perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
0, 0,
click() click(),
) ),
) )
val linearLayout3 = onView( val linearLayout3 =
allOf( onView(
withId(R.id.cameraButton), allOf(
childAtPosition( withId(R.id.cameraButton),
allOf( childAtPosition(
withId(R.id.nearby_button_layout), allOf(
withId(R.id.nearby_button_layout),
),
0,
), ),
0 isDisplayed(),
), ),
isDisplayed()
) )
)
linearLayout3.perform(click()) linearLayout3.perform(click())
val pasteSensitiveTextInputEditText = onView( val pasteSensitiveTextInputEditText =
allOf( onView(
withId(R.id.caption_item_edit_text), allOf(
childAtPosition( withId(R.id.caption_item_edit_text),
childAtPosition( childAtPosition(
withId(R.id.caption_item_edit_text_input_layout), childAtPosition(
0 withId(R.id.caption_item_edit_text_input_layout),
0,
),
0,
), ),
0 isDisplayed(),
), ),
isDisplayed()
) )
)
pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard()) pasteSensitiveTextInputEditText.perform(replaceText("test"), closeSoftKeyboard())
val pasteSensitiveTextInputEditText2 = onView( val pasteSensitiveTextInputEditText2 =
allOf( onView(
withId(R.id.description_item_edit_text), allOf(
childAtPosition( withId(R.id.description_item_edit_text),
childAtPosition( childAtPosition(
withId(R.id.description_item_edit_text_input_layout), childAtPosition(
0 withId(R.id.description_item_edit_text_input_layout),
0,
),
0,
), ),
0 isDisplayed(),
), ),
isDisplayed()
) )
)
pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard()) pasteSensitiveTextInputEditText2.perform(replaceText("test"), closeSoftKeyboard())
val appCompatButton2 = onView( val appCompatButton2 =
allOf( onView(
withId(R.id.btn_next), allOf(
childAtPosition( withId(R.id.btn_next),
childAtPosition( childAtPosition(
withId(R.id.ll_container_media_detail), childAtPosition(
2 withId(R.id.ll_container_media_detail),
2,
),
1,
), ),
1 isDisplayed(),
), ),
isDisplayed()
) )
)
appCompatButton2.perform(click()) appCompatButton2.perform(click())
val appCompatButton3 = onView( val appCompatButton3 =
allOf( onView(
withId(android.R.id.button1), allOf(
withId(android.R.id.button1),
),
) )
)
appCompatButton3.perform(scrollTo(), click()) appCompatButton3.perform(scrollTo(), click())
Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name)) Intents.intended(IntentMatchers.hasComponent(LocationPickerActivity::class.java.name))
val floatingActionButton3 = onView( val floatingActionButton3 =
allOf( onView(
withId(R.id.location_chosen_button), allOf(
isDisplayed() withId(R.id.location_chosen_button),
isDisplayed(),
),
) )
)
UITestHelper.sleep(2000) UITestHelper.sleep(2000)
floatingActionButton3.perform(click()) floatingActionButton3.perform(click())
} }

View file

@ -19,7 +19,10 @@ import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasType import androidx.test.espresso.intent.matcher.IntentMatchers.hasType
import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withParent
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.rule.ActivityTestRule import androidx.test.rule.ActivityTestRule
@ -29,21 +32,29 @@ import fr.free.nrw.commons.upload.UploadMediaDetailAdapter
import fr.free.nrw.commons.util.MyViewAction import fr.free.nrw.commons.util.MyViewAction
import fr.free.nrw.commons.utils.ConfigUtils import fr.free.nrw.commons.utils.ConfigUtils
import org.hamcrest.core.AllOf.allOf import org.hamcrest.core.AllOf.allOf
import org.junit.* import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Random
@LargeTest @LargeTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class UploadTest { class UploadTest {
@get:Rule @get:Rule
var permissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE, var permissionRule =
Manifest.permission.ACCESS_FINE_LOCATION)!! GrantPermissionRule.grant(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_FINE_LOCATION,
)!!
@get:Rule @get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java) var activityRule = ActivityTestRule(LoginActivity::class.java)
@ -61,7 +72,6 @@ class UploadTest {
try { try {
Intents.init() Intents.init()
} catch (ex: IllegalStateException) { } catch (ex: IllegalStateException) {
} }
UITestHelper.loginUser() UITestHelper.loginUser()
UITestHelper.skipWelcome() UITestHelper.skipWelcome()
@ -94,14 +104,13 @@ class UploadTest {
dismissWarning("Yes") dismissWarning("Yes")
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) 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))) 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))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
UITestHelper.sleep(5000) UITestHelper.sleep(5000)
dismissWarning("Yes") dismissWarning("Yes")
@ -109,29 +118,30 @@ class UploadTest {
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search))) 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) UITestHelper.sleep(3000)
try { try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click()) .perform(click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
onView(allOf(isDisplayed(), withId(R.id.btn_next))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
dismissWarning("Yes, Submit") dismissWarning("Yes, Submit")
UITestHelper.sleep(500) UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit))) onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click()) .perform(click())
UITestHelper.sleep(10000) 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" commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl") Timber.i("File should be uploaded to $fileUrl")
} }
@ -139,8 +149,8 @@ class UploadTest {
private fun dismissWarning(warningText: String) { private fun dismissWarning(warningText: String) {
try { try {
onView(withText(warningText)) onView(withText(warningText))
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
.perform(click()) .perform(click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
} }
@ -167,10 +177,10 @@ class UploadTest {
dismissWarning("Yes") dismissWarning("Yes")
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) onView(allOf<View>(isDisplayed(), withId(R.id.tv_title)))
.perform(replaceText(commonsFileName)) .perform(replaceText(commonsFileName))
onView(allOf(isDisplayed(), withId(R.id.btn_next))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
UITestHelper.sleep(10000) UITestHelper.sleep(10000)
dismissWarning("Yes") dismissWarning("Yes")
@ -178,29 +188,30 @@ class UploadTest {
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search))) onView(allOf(isDisplayed(), withId(R.id.et_search)))
.perform(replaceText("Test")) .perform(replaceText("Test"))
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
try { try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click()) .perform(click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
onView(allOf(isDisplayed(), withId(R.id.btn_next))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
dismissWarning("Yes, Submit") dismissWarning("Yes, Submit")
UITestHelper.sleep(500) UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit))) onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click()) .perform(click())
UITestHelper.sleep(10000) 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" commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl") Timber.i("File should be uploaded to $fileUrl")
} }
@ -227,23 +238,29 @@ class UploadTest {
dismissWarningDialog() dismissWarningDialog()
onView(allOf<View>(isDisplayed(), withId(R.id.tv_title))) onView(allOf<View>(isDisplayed(), withId(R.id.tv_title)))
.perform(replaceText(commonsFileName)) .perform(replaceText(commonsFileName))
onView(withId(R.id.rv_descriptions)).perform( onView(withId(R.id.rv_descriptions)).perform(
RecyclerViewActions RecyclerViewActions
.actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(0, .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"))) 0,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Test description"),
),
)
onView(withId(R.id.btn_add)) onView(withId(R.id.btn_add))
.perform(click()) .perform(click())
onView(withId(R.id.rv_descriptions)).perform( onView(withId(R.id.rv_descriptions)).perform(
RecyclerViewActions RecyclerViewActions
.actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(1, .actionOnItemAtPosition<UploadMediaDetailAdapter.ViewHolder>(
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"))) 1,
MyViewAction.typeTextInChildViewWithId(R.id.description_item_edit_text, "Description"),
),
)
onView(allOf(isDisplayed(), withId(R.id.btn_next))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
UITestHelper.sleep(5000) UITestHelper.sleep(5000)
dismissWarning("Yes") dismissWarning("Yes")
@ -251,29 +268,30 @@ class UploadTest {
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
onView(allOf(isDisplayed(), withId(R.id.et_search))) onView(allOf(isDisplayed(), withId(R.id.et_search)))
.perform(replaceText("Test")) .perform(replaceText("Test"))
UITestHelper.sleep(3000) UITestHelper.sleep(3000)
try { try {
onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories))))) onView(allOf(isDisplayed(), UITestHelper.first(withParent(withId(R.id.rv_categories)))))
.perform(click()) .perform(click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
onView(allOf(isDisplayed(), withId(R.id.btn_next))) onView(allOf(isDisplayed(), withId(R.id.btn_next)))
.perform(click()) .perform(click())
dismissWarning("Yes, Submit") dismissWarning("Yes, Submit")
UITestHelper.sleep(500) UITestHelper.sleep(500)
onView(allOf(isDisplayed(), withId(R.id.btn_submit))) onView(allOf(isDisplayed(), withId(R.id.btn_submit)))
.perform(click()) .perform(click())
UITestHelper.sleep(10000) 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" commonsFileName.replace(' ', '_') + ".jpg"
Timber.i("File should be uploaded to $fileUrl") Timber.i("File should be uploaded to $fileUrl")
} }
@ -306,7 +324,6 @@ class UploadTest {
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
} }
} }
} }
@ -328,8 +345,8 @@ class UploadTest {
private fun dismissWarningDialog() { private fun dismissWarningDialog() {
try { try {
onView(withText("Yes")) onView(withText("Yes"))
.check(matches(isDisplayed())) .check(matches(isDisplayed()))
.perform(click()) .perform(click())
} catch (ignored: NoMatchingViewException) { } catch (ignored: NoMatchingViewException) {
} }
} }
@ -337,10 +354,10 @@ class UploadTest {
private fun openGallery() { private fun openGallery() {
// Open FAB // Open FAB
onView(allOf<View>(withId(R.id.fab_plus), isDisplayed())) onView(allOf<View>(withId(R.id.fab_plus), isDisplayed()))
.perform(click()) .perform(click())
// Click gallery // Click gallery
onView(allOf<View>(withId(R.id.fab_gallery), isDisplayed())) onView(allOf<View>(withId(R.id.fab_gallery), isDisplayed()))
.perform(click()) .perform(click())
} }
} }

View file

@ -3,7 +3,6 @@ package fr.free.nrw.commons
import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches 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.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@ -22,7 +21,6 @@ import org.junit.runner.RunWith
@LargeTest @LargeTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class WelcomeActivityTest { class WelcomeActivityTest {
@get:Rule @get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java) var activityRule: ActivityTestRule<*> = ActivityTestRule(WelcomeActivity::class.java)
@ -130,4 +128,4 @@ class WelcomeActivityTest {
fun orientationChange() { fun orientationChange() {
UITestHelper.changeOrientation(activityRule) UITestHelper.changeOrientation(activityRule)
} }
} }

View file

@ -11,7 +11,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PasteSensitiveTextInputEditTextTest { class PasteSensitiveTextInputEditTextTest {
private var context: Context? = null private var context: Context? = null
private var textView: PasteSensitiveTextInputEditText? = null private var textView: PasteSensitiveTextInputEditText? = null
@ -23,9 +22,13 @@ class PasteSensitiveTextInputEditTextTest {
// this test has no real value, just % for test code coverage // this test has no real value, just % for test code coverage
@Test @Test
fun extractFormattingAttributeSet(){ fun extractFormattingAttributeSet() {
val methodExtractFormattingAttribute = textView!!.javaClass.getDeclaredMethod( val methodExtractFormattingAttribute =
"extractFormattingAttribute", Context::class.java, AttributeSet::class.java) textView!!.javaClass.getDeclaredMethod(
"extractFormattingAttribute",
Context::class.java,
AttributeSet::class.java,
)
methodExtractFormattingAttribute.isAccessible = true methodExtractFormattingAttribute.isAccessible = true
methodExtractFormattingAttribute.invoke(textView, context, null) methodExtractFormattingAttribute.invoke(textView, context, null)
} }
@ -40,4 +43,4 @@ class PasteSensitiveTextInputEditTextTest {
textView!!.setFormattingAllowed(false) textView!!.setFormattingAllowed(false)
Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView)) Assert.assertFalse(fieldFormattingAllowed.getBoolean(textView))
} }
} }

View file

@ -9,56 +9,58 @@ import org.hamcrest.Matcher
class MyViewAction { class MyViewAction {
companion object { companion object {
fun typeTextInChildViewWithId(id: Int, textToBeTyped: String): ViewAction { fun typeTextInChildViewWithId(
return object : ViewAction { id: Int,
override fun getConstraints(): Matcher<View>? { textToBeTyped: String,
return null ): ViewAction =
} object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
override fun getDescription(): String { override fun getDescription(): String = "Click on a child view with specified id."
return "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 val v = view.findViewById<View>(id) as EditText
v.setText(textToBeTyped) v.setText(textToBeTyped)
} }
} }
}
fun selectSpinnerItemInChildViewWithId(id: Int, position: Int): ViewAction { fun selectSpinnerItemInChildViewWithId(
return object : ViewAction { id: Int,
override fun getConstraints(): Matcher<View>? { position: Int,
return null ): ViewAction =
} object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
override fun getDescription(): String { override fun getDescription(): String = "Click on a child view with specified id."
return "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 val v = view.findViewById<View>(id) as AppCompatSpinner
v.setSelection(position) v.setSelection(position)
} }
} }
}
fun clickItemWithId(id: Int, position: Int): ViewAction { fun clickItemWithId(
return object : ViewAction { id: Int,
override fun getConstraints(): Matcher<View>? { position: Int,
return null ): ViewAction =
} object : ViewAction {
override fun getConstraints(): Matcher<View>? = null
override fun getDescription(): String { override fun getDescription(): String = "Click on a child view with specified id."
return "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 val v = view.findViewById<View>(id) as View
v.performClick() v.performClick()
} }
} }
}
} }
} }

View file

@ -39,26 +39,25 @@ class BaseMarker {
constructor() { constructor() {
} }
fun fromResource(context: Context, drawableResId: Int) { fun fromResource(
context: Context,
drawableResId: Int,
) {
val drawable: Drawable = context.resources.getDrawable(drawableResId) val drawable: Drawable = context.resources.getDrawable(drawableResId)
icon = if (drawable is BitmapDrawable) { icon =
(drawable as BitmapDrawable).bitmap if (drawable is BitmapDrawable) {
} else { (drawable as BitmapDrawable).bitmap
val bitmap = Bitmap.createBitmap( } else {
drawable.intrinsicWidth, val bitmap =
drawable.intrinsicHeight, Bitmap.Config.ARGB_8888 Bitmap.createBitmap(
) drawable.intrinsicWidth,
val canvas = Canvas(bitmap) drawable.intrinsicHeight,
drawable.setBounds(0, 0, canvas.width, canvas.height) Bitmap.Config.ARGB_8888,
drawable.draw(canvas) )
bitmap val canvas = Canvas(bitmap)
} drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
bitmap
}
} }
} }

View file

@ -10,9 +10,10 @@ object BetaConstants {
* production server where beta server does not work * production server where beta server does not work
*/ */
const val COMMONS_URL = "https://commons.wikimedia.org/" const val COMMONS_URL = "https://commons.wikimedia.org/"
/** /**
* Commons production's depicts property which is used in beta for some specific GET calls on * Commons production's depicts property which is used in beta for some specific GET calls on
* production server where beta server does not work * production server where beta server does not work
*/ */
const val DEPICTS_PROPERTY = "P180" const val DEPICTS_PROPERTY = "P180"
} }

View file

@ -3,31 +3,31 @@ package fr.free.nrw.commons
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable 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( constructor(parcel: Parcel) : this(
parcel.readDouble(), parcel.readDouble(),
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(latitude)
parcel.writeDouble(longitude) parcel.writeDouble(longitude)
parcel.writeDouble(zoom) parcel.writeDouble(zoom)
} }
override fun describeContents(): Int { override fun describeContents(): Int = 0
return 0
}
companion object CREATOR : Parcelable.Creator<CameraPosition> { companion object CREATOR : Parcelable.Creator<CameraPosition> {
override fun createFromParcel(parcel: Parcel): CameraPosition { override fun createFromParcel(parcel: Parcel): CameraPosition = CameraPosition(parcel)
return CameraPosition(parcel)
}
override fun newArray(size: Int): Array<CameraPosition?> { override fun newArray(size: Int): Array<CameraPosition?> = arrayOfNulls(size)
return arrayOfNulls(size)
}
} }
} }

View file

@ -2,9 +2,11 @@ package fr.free.nrw.commons
import android.os.Parcelable import android.os.Parcelable
import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.location.LatLng
import kotlinx.parcelize.Parcelize
import fr.free.nrw.commons.wikidata.model.page.PageTitle import fr.free.nrw.commons.wikidata.model.page.PageTitle
import java.util.* import kotlinx.parcelize.Parcelize
import java.util.Date
import java.util.Locale
import java.util.UUID
@Parcelize @Parcelize
class Media constructor( class Media constructor(
@ -14,7 +16,6 @@ class Media constructor(
*/ */
var pageId: String = UUID.randomUUID().toString(), var pageId: String = UUID.randomUUID().toString(),
var thumbUrl: String? = null, var thumbUrl: String? = null,
/** /**
* Gets image URL * Gets image URL
* @return Image URL * @return Image URL
@ -26,16 +27,11 @@ class Media constructor(
*/ */
var filename: String? = null, var filename: String? = null,
/** /**
* Gets the file description. * Gets or sets the file description.
* @return file description as a string * @return file description as a string
*/
// monolingual description on input...
/**
* Sets the file description.
* @param fallbackDescription the new description of the file * @param fallbackDescription the new description of the file
*/ */
var fallbackDescription: String? = null, var fallbackDescription: String? = null,
/** /**
* Gets the upload date of the file. * Gets the upload date of the file.
* Can be null. * Can be null.
@ -43,28 +39,19 @@ class Media constructor(
*/ */
var dateUploaded: Date? = null, var dateUploaded: Date? = null,
/** /**
* Gets the license name of the file. * Gets or sets the license name of the file.
* @return license as a String * @return license as a String
*/
/**
* Sets the license name of the file.
*
* @param license license name as a String * @param license license name as a String
*/ */
var license: String? = null, var license: String? = null,
var licenseUrl: String? = null, var licenseUrl: String? = null,
/** /**
* Gets the name of the creator of the file. * Gets or sets the name of the creator of the file.
* @return author name as a String * @return author name as a String
*/
/**
* Sets the author name of the file.
* @param author creator name as a string * @param author creator name as a string
*/ */
var author: String? = null, var author: String? = null,
var user: String? = null,
var user:String?=null,
/** /**
* Gets the categories the file falls under. * Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings * @return file categories as an ArrayList of Strings
@ -83,23 +70,23 @@ class Media constructor(
* Stores the mapping of category title to hidden attribute * Stores the mapping of category title to hidden attribute
* Example: "Mountains" => false, "CC-BY-SA-2.0" => true * Example: "Mountains" => false, "CC-BY-SA-2.0" => true
*/ */
var categoriesHiddenStatus: Map<String, Boolean> = emptyMap() var categoriesHiddenStatus: Map<String, Boolean> = emptyMap(),
) : Parcelable { ) : Parcelable {
constructor( constructor(
captions: Map<String, String>, captions: Map<String, String>,
categories: List<String>?, categories: List<String>?,
filename: String?, filename: String?,
fallbackDescription: String?, fallbackDescription: String?,
author: String?, user:String? author: String?,
user: String?,
) : this( ) : this(
filename = filename, filename = filename,
fallbackDescription = fallbackDescription, fallbackDescription = fallbackDescription,
dateUploaded = Date(), dateUploaded = Date(),
author = author, author = author,
user=user, user = user,
categories = categories, categories = categories,
captions = captions captions = captions,
) )
/** /**
@ -108,10 +95,11 @@ class Media constructor(
*/ */
val displayTitle: String val displayTitle: String
get() = get() =
if (filename != null) if (filename != null) {
pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "") pageTitle.displayTextWithoutNamespace.replaceFirst("[.][^.]+$".toRegex(), "")
else } else {
"" ""
}
/** /**
* Gets file page title * Gets file page title
@ -127,9 +115,10 @@ class Media constructor(
get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption) get() = String.format("[[%s|thumb|%s]]", filename, mostRelevantCaption)
val mostRelevantCaption: String val mostRelevantCaption: String
get() = captions[Locale.getDefault().language] get() =
?: captions.values.firstOrNull() captions[Locale.getDefault().language]
?: displayTitle ?: captions.values.firstOrNull()
?: displayTitle
/** /**
* Gets the categories the file falls under. * Gets the categories the file falls under.
@ -138,6 +127,8 @@ class Media constructor(
var addedCategories: List<String>? = null var addedCategories: List<String>? = null
// TODO added categories should be removed. It is added for a short fix. On category update, // TODO added categories should be removed. It is added for a short fix. On category update,
// categories should be re-fetched instead // categories should be re-fetched instead
get() = field // getter get() = field // getter
set(value) { field = value } // setter set(value) {
field = value
} // setter
} }

View file

@ -1,9 +1,9 @@
package fr.free.nrw.commons package fr.free.nrw.commons
import androidx.core.text.HtmlCompat 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.IdAndCaptions
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import io.reactivex.Single import io.reactivex.Single
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -17,42 +17,46 @@ import javax.inject.Singleton
* to the media and may change due to editing. * to the media and may change due to editing.
*/ */
@Singleton @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) = fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
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) = fun fetchDiscussion(media: Media) =
mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename) 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) = fun refresh(media: Media): Single<Media> =
mediaClient.getPageHtml(media.filename!!.replace("File", "File talk")) Single.ambArray(
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() } mediaClient
.onErrorReturn { .getMediaById(PAGE_ID_PREFIX + media.pageId)
Timber.d("Error occurred while fetching discussion") .onErrorResumeNext { Single.never() },
"" mediaClient
} .getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() },
)
fun refresh(media: Media): Single<Media> { fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
return Single.ambArray(
mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId)
.onErrorResumeNext { Single.never() },
mediaClient.getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() }
)
/**
* 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);
}

View file

@ -10,7 +10,9 @@ internal object Urls {
const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md" const val FAQ_URL = "https://github.com/commons-app/commons-app-documentation/blob/master/android/Frequently-Asked-Questions.md"
const val PLAY_STORE_PREFIX = "market://details?id=" const val PLAY_STORE_PREFIX = "market://details?id="
const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id=" const val PLAY_STORE_URL_PREFIX = "https://play.google.com/store/apps/details?id="
const val TRANSLATE_WIKI_URL = "https://translatewiki.net/w/i.php?title=Special:Translate&group=commons-android-strings&filter=%21translated&action=translate&language=" const val TRANSLATE_WIKI_URL =
"https://translatewiki.net/w/i.php?title=Special:Translate" +
"&group=commons-android-strings&filter=%21translated&action=translate&language="
const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985" const val FACEBOOK_WEB_URL = "https://www.facebook.com/1921335171459985"
const val FACEBOOK_APP_URL = "fb://page/1921335171459985" const val FACEBOOK_APP_URL = "fb://page/1921335171459985"
const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana" const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"

View file

@ -1,10 +1,9 @@
package fr.free.nrw.commons.actions package fr.free.nrw.commons.actions
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.Single 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 * This class acts as a Client to facilitate wiki page editing
@ -15,9 +14,8 @@ import timber.log.Timber
*/ */
class PageEditClient( class PageEditClient(
private val csrfTokenClient: CsrfTokenClient, private val csrfTokenClient: CsrfTokenClient,
private val pageEditInterface: PageEditInterface private val pageEditInterface: PageEditInterface,
) { ) {
/** /**
* Replace the content of a wiki page * Replace the content of a wiki page
* @param pageTitle Title of the page to edit * @param pageTitle Title of the page to edit
@ -25,12 +23,17 @@ class PageEditClient(
* @param summary Edit summary * @param summary Edit summary
* @return whether the edit was successful * @return whether the edit was successful
*/ */
fun edit(pageTitle: String, text: String, summary: String): Observable<Boolean> { fun edit(
return try { pageTitle: String,
pageEditInterface.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking()) text: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postEdit(pageTitle, summary, text, csrfTokenClient.getTokenBlocking())
.map { editResponse -> .map { editResponse ->
editResponse.edit()!!.editSucceeded() editResponse.edit()!!.editSucceeded()
} }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
throw throwable throw throwable
@ -38,7 +41,6 @@ class PageEditClient(
Observable.just(false) Observable.just(false)
} }
} }
}
/** /**
* Creates a new page with the given title, text, and summary. * 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. * @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. * @throws InvalidLoginTokenException If an invalid login token is encountered during the process.
*/ */
fun postCreate(pageTitle: String, text: String, summary: String): Observable<Boolean> { fun postCreate(
return try { pageTitle: String,
pageEditInterface.postCreate( text: String,
pageTitle, summary: String,
summary, ): Observable<Boolean> =
text, try {
"text/x-wiki", pageEditInterface
"wikitext", .postCreate(
true, pageTitle,
true, summary,
csrfTokenClient.getTokenBlocking() text,
).map { editResponse -> "text/x-wiki",
editResponse.edit()!!.editSucceeded() "wikitext",
} true,
true,
csrfTokenClient.getTokenBlocking(),
).map { editResponse ->
editResponse.edit()!!.editSucceeded()
}
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
throw throwable throw throwable
@ -70,7 +77,6 @@ class PageEditClient(
Observable.just(false) Observable.just(false)
} }
} }
}
/** /**
* Append text to the end of a wiki page * Append text to the end of a wiki page
@ -79,9 +85,14 @@ class PageEditClient(
* @param summary Edit summary * @param summary Edit summary
* @return whether the edit was successful * @return whether the edit was successful
*/ */
fun appendEdit(pageTitle: String, appendText: String, summary: String): Observable<Boolean> { fun appendEdit(
return try { pageTitle: String,
pageEditInterface.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking()) appendText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postAppendEdit(pageTitle, summary, appendText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()!!.editSucceeded() } .map { editResponse -> editResponse.edit()!!.editSucceeded() }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
@ -90,7 +101,6 @@ class PageEditClient(
Observable.just(false) Observable.just(false)
} }
} }
}
/** /**
* Prepend text to the beginning of a wiki page * Prepend text to the beginning of a wiki page
@ -99,9 +109,14 @@ class PageEditClient(
* @param summary Edit summary * @param summary Edit summary
* @return whether the edit was successful * @return whether the edit was successful
*/ */
fun prependEdit(pageTitle: String, prependText: String, summary: String): Observable<Boolean> { fun prependEdit(
return try { pageTitle: String,
pageEditInterface.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking()) prependText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postPrependEdit(pageTitle, summary, prependText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()?.editSucceeded() ?: false } .map { editResponse -> editResponse.edit()?.editSucceeded() ?: false }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
@ -110,8 +125,6 @@ class PageEditClient(
Observable.just(false) Observable.just(false)
} }
} }
}
/** /**
* Appends a new section to the wiki page * Appends a new section to the wiki page
@ -121,9 +134,15 @@ class PageEditClient(
* @param summary Edit summary * @param summary Edit summary
* @return whether the edit was successful * @return whether the edit was successful
*/ */
fun createNewSection(pageTitle: String, sectionTitle: String, sectionText: String, summary: String): Observable<Boolean> { fun createNewSection(
return try { pageTitle: String,
pageEditInterface.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking()) sectionTitle: String,
sectionText: String,
summary: String,
): Observable<Boolean> =
try {
pageEditInterface
.postNewSection(pageTitle, summary, sectionTitle, sectionText, csrfTokenClient.getTokenBlocking())
.map { editResponse -> editResponse.edit()!!.editSucceeded() } .map { editResponse -> editResponse.edit()!!.editSucceeded() }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
@ -132,8 +151,6 @@ class PageEditClient(
Observable.just(false) Observable.just(false)
} }
} }
}
/** /**
* Set new labels to Wikibase server of commons * Set new labels to Wikibase server of commons
@ -143,12 +160,21 @@ class PageEditClient(
* @param value label * @param value label
* @return 1 when the edit was successful * @return 1 when the edit was successful
*/ */
fun setCaptions(summary: String, title: String, fun setCaptions(
language: String, value: String) : Observable<Int>{ summary: String,
return try { title: String,
pageEditInterface.postCaptions(summary, title, language, language: String,
value, csrfTokenClient.getTokenBlocking() value: String,
).map { it.success } ): Observable<Int> =
try {
pageEditInterface
.postCaptions(
summary,
title,
language,
value,
csrfTokenClient.getTokenBlocking(),
).map { it.success }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is InvalidLoginTokenException) { if (throwable is InvalidLoginTokenException) {
throw throwable throw throwable
@ -156,16 +182,20 @@ class PageEditClient(
Observable.just(0) Observable.just(0)
} }
} }
}
/** /**
* Get whole WikiText of required file * Get whole WikiText of required file
* @param title : Name of the file * @param title : Name of the file
* @return Observable<MwQueryResult> * @return Observable<MwQueryResult>
*/ */
fun getCurrentWikiText(title: String): Single<String?> { fun getCurrentWikiText(title: String): Single<String?> =
return pageEditInterface.getWikiText(title).map { pageEditInterface.getWikiText(title).map {
it.query()?.pages()?.get(0)?.revisions()?.get(0)?.content() it
.query()
?.pages()
?.get(0)
?.revisions()
?.get(0)
?.content()
} }
}
} }

View file

@ -3,10 +3,15 @@ package fr.free.nrw.commons.actions
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX 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.Entities
import fr.free.nrw.commons.wikidata.model.edit.Edit import fr.free.nrw.commons.wikidata.model.edit.Edit
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.Single import io.reactivex.Single
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import retrofit2.http.Field
import retrofit2.http.* import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
/** /**
* This interface facilitates wiki commons page editing services to the Networking module * This interface facilitates wiki commons page editing services to the Networking module
@ -33,7 +38,7 @@ interface PageEditInterface {
@Field("summary") summary: String, @Field("summary") summary: String,
@Field("text") text: String, @Field("text") text: String,
// NOTE: This csrf shold always be sent as the last field of form data // NOTE: This csrf shold always be sent as the last field of form data
@Field("token") token: String @Field("token") token: String,
): Observable<Edit> ): Observable<Edit>
/** /**
@ -60,7 +65,7 @@ interface PageEditInterface {
@Field("minor") minor: Boolean, @Field("minor") minor: Boolean,
@Field("recreate") recreate: Boolean, @Field("recreate") recreate: Boolean,
// NOTE: This csrf shold always be sent as the last field of form data // NOTE: This csrf shold always be sent as the last field of form data
@Field("token") token: String @Field("token") token: String,
): Observable<Edit> ): Observable<Edit>
/** /**
@ -79,7 +84,7 @@ interface PageEditInterface {
@Field("title") title: String, @Field("title") title: String,
@Field("summary") summary: String, @Field("summary") summary: String,
@Field("appendtext") appendText: String, @Field("appendtext") appendText: String,
@Field("token") token: String @Field("token") token: String,
): Observable<Edit> ): Observable<Edit>
/** /**
@ -98,7 +103,7 @@ interface PageEditInterface {
@Field("title") title: String, @Field("title") title: String,
@Field("summary") summary: String, @Field("summary") summary: String,
@Field("prependtext") prependText: String, @Field("prependtext") prependText: String,
@Field("token") token: String @Field("token") token: String,
): Observable<Edit> ): Observable<Edit>
@FormUrlEncoded @FormUrlEncoded
@ -109,7 +114,7 @@ interface PageEditInterface {
@Field("summary") summary: String, @Field("summary") summary: String,
@Field("sectiontitle") sectionTitle: String, @Field("sectiontitle") sectionTitle: String,
@Field("text") sectionText: String, @Field("text") sectionText: String,
@Field("token") token: String @Field("token") token: String,
): Observable<Edit> ): Observable<Edit>
@FormUrlEncoded @FormUrlEncoded
@ -120,7 +125,7 @@ interface PageEditInterface {
@Field("title") title: String, @Field("title") title: String,
@Field("language") language: String, @Field("language") language: String,
@Field("value") value: String, @Field("value") value: String,
@Field("token") token: String @Field("token") token: String,
): Observable<Entities> ): Observable<Entities>
/** /**
@ -130,6 +135,6 @@ interface PageEditInterface {
*/ */
@GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=") @GET(MW_API_PREFIX + "action=query&prop=revisions&rvprop=content|timestamp&rvlimit=1&converttitles=")
fun getWikiText( fun getWikiText(
@Query("titles") title: String @Query("titles") title: String,
): Single<MwQueryResponse?> ): Single<MwQueryResponse?>
} }

View file

@ -1,11 +1,10 @@
package fr.free.nrw.commons.actions package fr.free.nrw.commons.actions
import fr.free.nrw.commons.CommonsApplication 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.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException 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.Inject
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton 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 * Thanks are used by a user to show gratitude to another user for their contributions
*/ */
@Singleton @Singleton
class ThanksClient @Inject constructor( class ThanksClient
@param:Named(NAMED_COMMONS_CSRF) private val csrfTokenClient: CsrfTokenClient, @Inject
private val service: ThanksInterface 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 * Thanks a user for a particular revision
*/ * @param revisionId The revision ID the user would like to thank someone for
fun thank(revisionId: Long): Observable<Boolean> { * @return if thanks was successfully sent to intended recipient
return try { */
service.thank( fun thank(revisionId: Long): Observable<Boolean> =
revisionId.toString(), // Rev try {
null, // Log service
csrfTokenClient.getTokenBlocking(), // Token .thank(
CommonsApplication.getInstance().userAgent // Source revisionId.toString(), // Rev
).map { null, // Log
mwThankPostResponse -> mwThankPostResponse.result?.success == 1 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)
}
}
} }
}

View file

@ -19,6 +19,6 @@ interface ThanksInterface {
@Field("rev") rev: String?, @Field("rev") rev: String?,
@Field("log") log: String?, @Field("log") log: String?,
@Field("token") token: String, @Field("token") token: String,
@Field("source") source: String? @Field("source") source: String?,
): Observable<MwThankPostResponse?> ): Observable<MwThankPostResponse?>
} }

View file

@ -2,11 +2,11 @@ package fr.free.nrw.commons.auth.csrf
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.auth.SessionManager 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.LoginCallback
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginFailedException import fr.free.nrw.commons.auth.login.LoginFailedException
import fr.free.nrw.commons.auth.login.LoginResult import fr.free.nrw.commons.auth.login.LoginResult
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import retrofit2.Call import retrofit2.Call
import retrofit2.Response import retrofit2.Response
import timber.log.Timber import timber.log.Timber
@ -17,12 +17,11 @@ class CsrfTokenClient(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val csrfTokenInterface: CsrfTokenInterface, private val csrfTokenInterface: CsrfTokenInterface,
private val loginClient: LoginClient, private val loginClient: LoginClient,
private val logoutClient: LogoutClient private val logoutClient: LogoutClient,
) { ) {
private var retries = 0 private var retries = 0
private var csrfTokenCall: Call<MwQueryResponse?>? = null private var csrfTokenCall: Call<MwQueryResponse?>? = null
@Throws(Throwable::class) @Throws(Throwable::class)
fun getTokenBlocking(): String { fun getTokenBlocking(): String {
var token = "" var token = ""
@ -37,11 +36,20 @@ class CsrfTokenClient(
} }
// Get CSRFToken response off the main thread. // Get CSRFToken response off the main thread.
val response = newSingleThreadExecutor().submit(Callable { val response =
csrfTokenInterface.getCsrfTokenCall().execute() newSingleThreadExecutor()
}).get() .submit(
Callable {
csrfTokenInterface.getCsrfTokenCall().execute()
},
).get()
if (response.body()?.query()?.csrfToken().isNullOrEmpty()) { if (response
.body()
?.query()
?.csrfToken()
.isNullOrEmpty()
) {
continue continue
} }
@ -51,9 +59,8 @@ class CsrfTokenClient(
} }
break break
} catch (e: LoginFailedException) { } catch (e: LoginFailedException) {
throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) throw InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE)
} } catch (t: Throwable) {
catch (t: Throwable) {
Timber.w(t) Timber.w(t)
} }
} }
@ -65,45 +72,65 @@ class CsrfTokenClient(
} }
@VisibleForTesting @VisibleForTesting
fun request(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> = fun request(
requestToken(service, object : Callback { service: CsrfTokenInterface,
override fun success(token: String?) { cb: Callback,
if (sessionManager.isUserLoggedIn && token == ANON_TOKEN) { ): Call<MwQueryResponse?> =
retryWithLogin(cb) { requestToken(
InvalidLoginTokenException(ANONYMOUS_TOKEN_MESSAGE) 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 @VisibleForTesting
fun requestToken(service: CsrfTokenInterface, cb: Callback): Call<MwQueryResponse?> { fun requestToken(
service: CsrfTokenInterface,
cb: Callback,
): Call<MwQueryResponse?> {
val call = service.getCsrfTokenCall() val call = service.getCsrfTokenCall()
call.enqueue(object : retrofit2.Callback<MwQueryResponse?> { call.enqueue(
override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { object : retrofit2.Callback<MwQueryResponse?> {
if (call.isCanceled) { override fun onResponse(
return 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) { override fun onFailure(
if (call.isCanceled) { call: Call<MwQueryResponse?>,
return t: Throwable,
) {
if (call.isCanceled) {
return
}
cb.failure(t)
} }
cb.failure(t) },
} )
})
return call return call
} }
private fun retryWithLogin(callback: Callback, caught: () -> Throwable?) { private fun retryWithLogin(
callback: Callback,
caught: () -> Throwable?,
) {
val userName = sessionManager.userName val userName = sessionManager.userName
val password = sessionManager.password val password = sessionManager.password
if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) { if (retries < MAX_RETRIES && !userName.isNullOrEmpty() && !password.isNullOrEmpty()) {
@ -123,26 +150,31 @@ class CsrfTokenClient(
username: String, username: String,
password: String, password: String,
callback: Callback, callback: Callback,
retryCallback: () -> Unit retryCallback: () -> Unit,
) = loginClient.request(username, password, object : LoginCallback { ) = loginClient.request(
override fun success(loginResult: LoginResult) { username,
if (loginResult.pass) { password,
sessionManager.updateAccount(loginResult) object : LoginCallback {
retryCallback() override fun success(loginResult: LoginResult) {
} else { if (loginResult.pass) {
callback.failure(LoginFailedException(loginResult.message)) sessionManager.updateAccount(loginResult)
retryCallback()
} else {
callback.failure(LoginFailedException(loginResult.message))
}
} }
}
override fun twoFactorPrompt(caught: Throwable, token: String?) = override fun twoFactorPrompt(
callback.twoFactorPrompt() caught: Throwable,
token: String?,
) = callback.twoFactorPrompt()
// Should not happen here, but call the callback just in case. // Should not happen here, but call the callback just in case.
override fun passwordResetPrompt(token: String?) = override fun passwordResetPrompt(token: String?) = callback.failure(LoginFailedException("Logged in with temporary password."))
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() { private fun cancel() {
loginClient.cancel() loginClient.cancel()
@ -154,7 +186,9 @@ class CsrfTokenClient(
interface Callback { interface Callback {
fun success(token: String?) fun success(token: String?)
fun failure(caught: Throwable?) fun failure(caught: Throwable?)
fun twoFactorPrompt() fun twoFactorPrompt()
} }
@ -166,5 +200,7 @@ class CsrfTokenClient(
const val ANONYMOUS_TOKEN_MESSAGE = "App believes we're logged in, but got anonymous token." 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)

View file

@ -3,6 +3,10 @@ package fr.free.nrw.commons.auth.csrf
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage import fr.free.nrw.commons.wikidata.cookies.CommonsCookieStorage
import javax.inject.Inject import javax.inject.Inject
class LogoutClient @Inject constructor(private val store: CommonsCookieStorage) { class LogoutClient
fun logout() = store.clear() @Inject
} constructor(
private val store: CommonsCookieStorage,
) {
fun logout() = store.clear()
}

View file

@ -2,7 +2,13 @@ package fr.free.nrw.commons.auth.login
interface LoginCallback { interface LoginCallback {
fun success(loginResult: LoginResult) fun success(loginResult: LoginResult)
fun twoFactorPrompt(caught: Throwable, token: String?)
fun twoFactorPrompt(
caught: Throwable,
token: String?,
)
fun passwordResetPrompt(token: String?) fun passwordResetPrompt(token: String?)
fun error(caught: Throwable) fun error(caught: Throwable)
} }

View file

@ -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.OAuthResult
import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult import fr.free.nrw.commons.auth.login.LoginResult.ResetPasswordResult
import fr.free.nrw.commons.wikidata.WikidataConstants.WIKIPEDIA_URL 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.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -16,7 +16,9 @@ import java.io.IOException
/** /**
* Responsible for making login related requests to the server. * 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 tokenCall: Call<MwQueryResponse?>? = null
private var loginCall: Call<LoginResponse?>? = null private var loginCall: Call<LoginResponse?>? = null
@ -30,80 +32,116 @@ class LoginClient(private val loginInterface: LoginInterface) {
private fun getLoginToken() = loginInterface.getLoginToken() private fun getLoginToken() = loginInterface.getLoginToken()
fun request(userName: String, password: String, cb: LoginCallback) { fun request(
userName: String,
password: String,
cb: LoginCallback,
) {
cancel() cancel()
tokenCall = getLoginToken() tokenCall = getLoginToken()
tokenCall!!.enqueue(object : Callback<MwQueryResponse?> { tokenCall!!.enqueue(
override fun onResponse(call: Call<MwQueryResponse?>, response: Response<MwQueryResponse?>) { object : Callback<MwQueryResponse?> {
login( override fun onResponse(
userName, password, null, null, response.body()!!.query()!!.loginToken(), call: Call<MwQueryResponse?>,
userLanguage, cb response: Response<MwQueryResponse?>,
) ) {
} login(
userName,
override fun onFailure(call: Call<MwQueryResponse?>, caught: Throwable) { password,
if (call.isCanceled) { null,
return 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( fun login(
userName: String, password: String, retypedPassword: String?, twoFactorCode: String?, userName: String,
loginToken: String?, userLanguage: String, cb: LoginCallback password: String,
retypedPassword: String?,
twoFactorCode: String?,
loginToken: String?,
userLanguage: String,
cb: LoginCallback,
) { ) {
this.userLanguage = userLanguage this.userLanguage = userLanguage
loginCall = if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) { loginCall =
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) {
} else { loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
loginInterface.postLogIn( } else {
userName, password, retypedPassword, twoFactorCode, loginToken, userLanguage, true loginInterface.postLogIn(
) userName,
} password,
retypedPassword,
twoFactorCode,
loginToken,
userLanguage,
true,
)
}
loginCall!!.enqueue(object : Callback<LoginResponse?> { loginCall!!.enqueue(
override fun onResponse( object : Callback<LoginResponse?> {
call: Call<LoginResponse?>, override fun onResponse(
response: Response<LoginResponse?> call: Call<LoginResponse?>,
) { response: Response<LoginResponse?>,
val loginResult = response.body()?.toLoginResult(password) ) {
if (loginResult != null) { val loginResult = response.body()?.toLoginResult(password)
if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) { if (loginResult != null) {
// The server could do some transformations on user names, e.g. on some if (loginResult.pass && !loginResult.userName.isNullOrEmpty()) {
// wikis is uppercases the first letter. // The server could do some transformations on user names, e.g. on some
getExtendedInfo(loginResult.userName, loginResult, cb) // wikis is uppercases the first letter.
} else if ("UI" == loginResult.status) { getExtendedInfo(loginResult.userName, loginResult, cb)
when (loginResult) { } else if ("UI" == loginResult.status) {
is OAuthResult -> cb.twoFactorPrompt( when (loginResult) {
LoginFailedException(loginResult.message), is OAuthResult ->
loginToken cb.twoFactorPrompt(
) LoginFailedException(loginResult.message),
loginToken,
)
is ResetPasswordResult -> cb.passwordResetPrompt(loginToken) is ResetPasswordResult -> cb.passwordResetPrompt(loginToken)
is LoginResult.Result -> cb.error( is LoginResult.Result ->
LoginFailedException(loginResult.message) cb.error(
) LoginFailedException(loginResult.message),
)
}
} else {
cb.error(LoginFailedException(loginResult.message))
} }
} else { } 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) { override fun onFailure(
if (call.isCanceled) { call: Call<LoginResponse?>,
return t: Throwable,
) {
if (call.isCanceled) {
return
}
cb.error(t)
} }
cb.error(t) },
} )
})
} }
fun doLogin( fun doLogin(
@ -111,43 +149,65 @@ class LoginClient(private val loginInterface: LoginInterface) {
password: String, password: String,
twoFactorCode: String, twoFactorCode: String,
userLanguage: String, userLanguage: String,
loginCallback: LoginCallback loginCallback: LoginCallback,
) { ) {
getLoginToken().enqueue(object :Callback<MwQueryResponse?>{ getLoginToken().enqueue(
override fun onResponse( object : Callback<MwQueryResponse?> {
call: Call<MwQueryResponse?>, override fun onResponse(
response: Response<MwQueryResponse?> call: Call<MwQueryResponse?>,
) = if (response.isSuccessful){ response: Response<MwQueryResponse?>,
val loginToken = response.body()?.query()?.loginToken() ) = if (response.isSuccessful) {
loginToken?.let { val loginToken = response.body()?.query()?.loginToken()
login(username, password, null, twoFactorCode, it, userLanguage, loginCallback) loginToken?.let {
} ?: run { 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")) 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) { override fun onFailure(
loginCallback.error(t) call: Call<MwQueryResponse?>,
} t: Throwable,
}) ) {
loginCallback.error(t)
}
},
)
} }
@Throws(Throwable::class) @Throws(Throwable::class)
fun loginBlocking(userName: String, password: String, twoFactorCode: String?) { fun loginBlocking(
userName: String,
password: String,
twoFactorCode: String?,
) {
val tokenResponse = getLoginToken().execute() val tokenResponse = getLoginToken().execute()
if (tokenResponse.body()?.query()?.loginToken().isNullOrEmpty()) { if (tokenResponse
.body()
?.query()
?.loginToken()
.isNullOrEmpty()
) {
throw IOException("Unexpected response when getting login token.") throw IOException("Unexpected response when getting login token.")
} }
val loginToken = tokenResponse.body()?.query()?.loginToken() val loginToken = tokenResponse.body()?.query()?.loginToken()
val tempLoginCall = if (twoFactorCode.isNullOrEmpty()) { val tempLoginCall =
loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL) if (twoFactorCode.isNullOrEmpty()) {
} else { loginInterface.postLogIn(userName, password, loginToken, userLanguage, WIKIPEDIA_URL)
loginInterface.postLogIn( } else {
userName, password, null, twoFactorCode, loginToken, userLanguage, true loginInterface.postLogIn(
) userName,
} password,
null,
twoFactorCode,
loginToken,
userLanguage,
true,
)
}
val response = tempLoginCall.execute() val response = tempLoginCall.execute()
val loginResponse = response.body() ?: throw IOException("Unexpected response when logging in.") 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) = private fun getExtendedInfo(
loginInterface.getUserInfo(userName) userName: String,
.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) loginResult: LoginResult,
.subscribe({ response: MwQueryResponse? -> cb: LoginCallback,
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 ) = loginInterface
loginResult.groups = .getUserInfo(userName)
response?.query()?.getUserResponse(userName)?.groups ?: emptySet() .subscribeOn(Schedulers.io())
cb.success(loginResult) .observeOn(AndroidSchedulers.mainThread())
}, { caught: Throwable -> .subscribe({ response: MwQueryResponse? ->
Timber.e(caught, "Login succeeded but getting group information failed. ") loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
cb.error(caught) 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() { fun cancel() {
tokenCall?.let { tokenCall?.let {

View file

@ -1,3 +1,5 @@
package fr.free.nrw.commons.auth.login package fr.free.nrw.commons.auth.login
class LoginFailedException(message: String?) : Throwable(message) class LoginFailedException(
message: String?,
) : Throwable(message)

View file

@ -1,8 +1,8 @@
package fr.free.nrw.commons.auth.login package fr.free.nrw.commons.auth.login
import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX import fr.free.nrw.commons.wikidata.WikidataConstants.MW_API_PREFIX
import io.reactivex.Observable
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Observable
import retrofit2.Call import retrofit2.Call
import retrofit2.http.Field import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
@ -24,7 +24,7 @@ interface LoginInterface {
@Field("password") pass: String?, @Field("password") pass: String?,
@Field("logintoken") token: String?, @Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?, @Field("uselang") userLanguage: String?,
@Field("loginreturnurl") url: String? @Field("loginreturnurl") url: String?,
): Call<LoginResponse?> ): Call<LoginResponse?>
@Headers("Cache-Control: no-cache") @Headers("Cache-Control: no-cache")
@ -37,9 +37,11 @@ interface LoginInterface {
@Field("OATHToken") twoFactorCode: String?, @Field("OATHToken") twoFactorCode: String?,
@Field("logintoken") token: String?, @Field("logintoken") token: String?,
@Field("uselang") userLanguage: String?, @Field("uselang") userLanguage: String?,
@Field("logincontinue") loginContinue: Boolean @Field("logincontinue") loginContinue: Boolean,
): Call<LoginResponse?> ): Call<LoginResponse?>
@GET(MW_API_PREFIX + "action=query&meta=userinfo&list=users&usprop=groups|cancreate") @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?>
}

View file

@ -13,9 +13,7 @@ class LoginResponse {
@SerializedName("clientlogin") @SerializedName("clientlogin")
private val clientLogin: ClientLogin? = null private val clientLogin: ClientLogin? = null
fun toLoginResult(password: String): LoginResult? { fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
return clientLogin?.toLoginResult(password)
}
} }
internal class ClientLogin { internal class ClientLogin {
@ -39,7 +37,7 @@ internal class ClientLogin {
} }
} }
} else if ("PASS" != status && "FAIL" != status) { } 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." userMessage = "An unknown error occurred."
} }
return Result(status ?: "", userName, password, userMessage) return Result(status ?: "", userName, password, userMessage)

View file

@ -4,7 +4,7 @@ sealed class LoginResult(
val status: String, val status: String,
val userName: String?, val userName: String?,
val password: String?, val password: String?,
val message: String? val message: String?,
) { ) {
var userId = 0 var userId = 0
var groups = emptySet<String>() var groups = emptySet<String>()
@ -14,20 +14,20 @@ sealed class LoginResult(
status: String, status: String,
userName: String?, userName: String?,
password: String?, password: String?,
message: String? message: String?,
): LoginResult(status, userName, password, message) ) : LoginResult(status, userName, password, message)
class OAuthResult( class OAuthResult(
status: String, status: String,
userName: String?, userName: String?,
password: String?, password: String?,
message: String? message: String?,
) : LoginResult(status, userName, password, message) ) : LoginResult(status, userName, password, message)
class ResetPasswordResult( class ResetPasswordResult(
status: String, status: String,
userName: String?, userName: String?,
password: String?, password: String?,
message: String? message: String?,
) : LoginResult(status, userName, password, message) ) : LoginResult(status, userName, password, message)
} }

View file

@ -15,25 +15,34 @@ import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
/** /**
* Helps to inflate Wikidata Items into Items tab * Helps to inflate Wikidata Items into Items tab
*/ */
class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context) : class BookmarkItemsAdapter(
RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() { val list: List<DepictedItem>,
val context: Context,
class BookmarkItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { ) : RecyclerView.Adapter<BookmarkItemsAdapter.BookmarkItemViewHolder>() {
class BookmarkItemViewHolder(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label) var depictsLabel: TextView = itemView.findViewById(R.id.depicts_label)
var description: TextView = itemView.findViewById(R.id.description) var description: TextView = itemView.findViewById(R.id.description)
var depictsImage: SimpleDraweeView = itemView.findViewById(R.id.depicts_image) 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 { override fun onCreateViewHolder(
val v: View = LayoutInflater.from(context) parent: ViewGroup,
.inflate(R.layout.item_depictions, parent, false) viewType: Int,
): BookmarkItemViewHolder {
val v: View =
LayoutInflater
.from(context)
.inflate(R.layout.item_depictions, parent, false)
return BookmarkItemViewHolder(v) return BookmarkItemViewHolder(v)
} }
override fun onBindViewHolder(holder: BookmarkItemViewHolder, position: Int) { override fun onBindViewHolder(
holder: BookmarkItemViewHolder,
position: Int,
) {
val depictedItem = list[position] val depictedItem = list[position]
holder.depictsLabel.text = depictedItem.name holder.depictsLabel.text = depictedItem.name
holder.description.text = depictedItem.description holder.description.text = depictedItem.description
@ -48,7 +57,5 @@ class BookmarkItemsAdapter (val list: List<DepictedItem>, val context: Context)
} }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int = list.size
return list.size }
}
}

View file

@ -2,25 +2,25 @@ package fr.free.nrw.commons.bookmarks.models
import android.net.Uri import android.net.Uri
class Bookmark(mediaName: String?, mediaCreator: String?, class Bookmark(
/** mediaName: String?,
* Modifies the content URI - marking this bookmark as already saved in the database mediaCreator: String?,
* @param contentUri the content URI
*/
var contentUri: Uri?) {
/** /**
* Gets the content URI for this bookmark * Gets or Sets the content URI - marking this bookmark as already saved in the database
* @return content URI * @return content URI
* @param contentUri the content URI
*/ */
var contentUri: Uri?,
) {
/** /**
* Gets the media name * Gets the media name
* @return the media name * @return the media name
*/ */
val mediaName: String = mediaName ?: "" val mediaName: String = mediaName ?: ""
/** /**
* Gets media creator * Gets media creator
* @return creator name * @return creator name
*/ */
val mediaCreator: String = mediaCreator ?: "" val mediaCreator: String = mediaCreator ?: ""
}
}

View file

@ -8,6 +8,7 @@ import com.google.gson.annotations.SerializedName
class CampaignConfig { class CampaignConfig {
@SerializedName("showOnlyLiveCampaigns") @SerializedName("showOnlyLiveCampaigns")
private val showOnlyLiveCampaigns = false private val showOnlyLiveCampaigns = false
@SerializedName("sortBy") @SerializedName("sortBy")
private val sortBy: String? = null private val sortBy: String? = null
} }

View file

@ -9,7 +9,7 @@ import fr.free.nrw.commons.campaigns.models.Campaign
class CampaignResponseDTO { class CampaignResponseDTO {
@SerializedName("config") @SerializedName("config")
val campaignConfig: CampaignConfig? = null val campaignConfig: CampaignConfig? = null
@SerializedName("campaigns") @SerializedName("campaigns")
val campaigns: List<Campaign>? = null val campaigns: List<Campaign>? = null
}
}

View file

@ -3,9 +3,11 @@ package fr.free.nrw.commons.campaigns.models
/** /**
* A data class to hold a campaign * A data class to hold a campaign
*/ */
data class Campaign(var title: String? = null, data class Campaign(
var description: String? = null, var title: String? = null,
var startDate: String? = null, var description: String? = null,
var endDate: String? = null, var startDate: String? = null,
var link: String? = null, var endDate: String? = null,
var isWLMCampaign: Boolean = false) var link: String? = null,
var isWLMCampaign: Boolean = false,
)

View file

@ -8,275 +8,287 @@ import fr.free.nrw.commons.utils.StringSortingUtils
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.functions.Function4 import io.reactivex.functions.Function4
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.Calendar
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
/** /**
* The model class for categories in upload * The model class for categories in upload
*/ */
class CategoriesModel @Inject constructor( class CategoriesModel
private val categoryClient: CategoryClient, @Inject
private val categoryDao: CategoryDao, constructor(
private val gpsCategoryModel: GpsCategoryModel private val categoryClient: CategoryClient,
) { private val categoryDao: CategoryDao,
private val selectedCategories: MutableList<CategoryItem> = mutableListOf() private val gpsCategoryModel: GpsCategoryModel,
) {
private val selectedCategories: MutableList<CategoryItem> = mutableListOf()
/** /**
* Existing categories which are selected * Existing categories which are selected
*/ */
private var selectedExistingCategories: MutableList<String> = mutableListOf() private var selectedExistingCategories: MutableList<String> = mutableListOf()
/** /**
* Returns true if an item is considered to be a spammy category which should be ignored * 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 * @param item a category item that needs to be validated to know if it is spammy or not
* @return * @return
*/ */
fun isSpammyCategory(item: String): Boolean { fun isSpammyCategory(item: String): Boolean {
//Check for current and previous year to exclude these categories from removal // Check for current and previous year to exclude these categories from removal
val now = Calendar.getInstance() val now = Calendar.getInstance()
val curYear = now[Calendar.YEAR] val curYear = now[Calendar.YEAR]
val curYearInString = curYear.toString() val curYearInString = curYear.toString()
val prevYear = curYear - 1 val prevYear = curYear - 1
val prevYearInString = prevYear.toString() val prevYearInString = prevYear.toString()
Timber.d("Previous year: %s", prevYearInString) Timber.d("Previous year: %s", prevYearInString)
val mentionsDecade = item.matches(".*0s.*".toRegex()) val mentionsDecade = item.matches(".*0s.*".toRegex())
val recentDecade = item.matches(".*20[0-2]0s.*".toRegex()) val recentDecade = item.matches(".*20[0-2]0s.*".toRegex())
val spammyCategory = item.matches("(.*)needing(.*)".toRegex()) val spammyCategory =
|| item.matches("(.*)taken on(.*)".toRegex()) 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) // always skip irrelevant categories such as Media_needing_categories_as_of_16_June_2017(Issue #750)
if (spammyCategory) { if (spammyCategory) {
return true 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 * Updates category count in category dao
// Example: "2020s" is OK, but "1920s" is not (and should be skipped) * @param item
return !recentDecade */
} else { fun updateCategoryCount(item: CategoryItem) {
// If it is not an year in decade form (e.g. 19xxs/20xxs), then check if item contains a 4-digit year var category = categoryDao.find(item.name)
// anywhere within the string (.* is wildcard) (Issue #47)
// And that item does not equal the current year or previous year // Newly used category...
return item.matches(".*(19|20)\\d{2}.*".toRegex()) if (category == null) {
&& !item.contains(curYearInString) category = Category(null, item.name, item.description, item.thumbnail, Date(), 0)
&& !item.contains(prevYearInString) }
category.incTimesUsed()
categoryDao.save(category)
} }
}
/** /**
* Updates category count in category dao * Regional category search
* @param item * @param term
*/ * @param imageTitleList
fun updateCategoryCount(item: CategoryItem) { * @return
var category = categoryDao.find(item.name) */
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... private fun suggestionsOrSearch(
if (category == null) { term: String,
category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) imageTitleList: List<String>,
} selectedDepictions: List<DepictedItem>,
category.incTimesUsed() ): Observable<List<CategoryItem>> =
categoryDao.save(category) 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 * Fetches details of every category associated with selected depictions, converts them into
* @param term * CategoryItem and returns them in a list.
* @param imageTitleList *
* @return * @param selectedDepictions selected DepictItems
*/ * @return List of CategoryItem associated with selected depictions
fun searchAll( */
term: String, private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): Observable<MutableList<CategoryItem>>? =
imageTitleList: List<String>, Observable
selectedDepictions: List<DepictedItem> .fromIterable(
): Observable<List<CategoryItem>> { selectedDepictions.map { it.commonsCategories }.flatten(),
return suggestionsOrSearch(term, imageTitleList, selectedDepictions) ).map { categoryItem ->
.map { it.map { CategoryItem(it.name, it.description, it.thumbnail, false) } } categoryClient
} .getCategoriesByName(
categoryItem.name,
private fun suggestionsOrSearch( categoryItem.name,
term: String, SEARCH_CATS_LIMIT,
imageTitleList: List<String>, ).map {
selectedDepictions: List<DepictedItem> CategoryItem(
): Observable<List<CategoryItem>> { it[0].name,
return if (TextUtils.isEmpty(term)) it[0].description,
Observable.combineLatest( it[0].thumbnail,
categoriesFromDepiction(selectedDepictions), it[0].isSelected,
gpsCategoryModel.categoriesFromLocation, )
titleCategories(imageTitleList), }.blockingGet()
Observable.just(categoryDao.recentCategories(SEARCH_CATS_LIMIT)), }.toList()
Function4(::combine)
)
else
categoryClient.searchCategoriesForPrefix(term, SEARCH_CATS_LIMIT)
.map { it.sortedWith(StringSortingUtils.sortBySimilarity(term)) }
.toObservable() .toObservable()
}
/** /**
* Fetches details of every category associated with selected depictions, converts them into * Fetches details of every category by their name, converts them into
* CategoryItem and returns them in a list. * CategoryItem and returns them in a list.
* *
* @param selectedDepictions selected DepictItems * @param categoryNames selected Categories
* @return List of CategoryItem associated with selected depictions * @return List of CategoryItem
*/ */
private fun categoriesFromDepiction(selectedDepictions: List<DepictedItem>): fun getCategoriesByName(categoryNames: List<String>): Observable<MutableList<CategoryItem>>? =
Observable<MutableList<CategoryItem>>? { Observable
return Observable.fromIterable( .fromIterable(categoryNames)
selectedDepictions.map { it.commonsCategories }.flatten()) .map { categoryName ->
.map { categoryItem -> buildCategories(categoryName)
categoryClient.getCategoriesByName(categoryItem.name, }.filter { categoryItem ->
categoryItem.name, SEARCH_CATS_LIMIT).map { 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() private fun combine(
}.toList().toObservable() 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 * Returns title based categories
* CategoryItem and returns them in a list. * @param titleList
* * @return
* @param categoryNames selected Categories */
* @return List of CategoryItem private fun titleCategories(titleList: List<String>) =
*/ if (titleList.isNotEmpty()) {
fun getCategoriesByName(categoryNames: List<String>): Observable.combineLatest(titleList.map { getTitleCategories(it) }) { searchResults ->
Observable<MutableList<CategoryItem>>? { searchResults.map { it as List<CategoryItem> }.flatten()
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)
} }
} else { } else {
if (media.categories?.contains(item.name) == true) { Observable.just(emptyList())
selectedExistingCategories.remove(item.name) }
if (!media.categories?.contains(item.name)!!) {
val categoriesList: MutableList<String> = ArrayList() /**
categoriesList.add(item.name) * Return category for single title
categoriesList.addAll(media.categories!!) * @param title
media.categories = categoriesList * @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 { } else {
selectedCategories.remove(item) 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 * Get Selected Categories
* @return * @return
*/ */
fun getSelectedCategories(): List<CategoryItem> { fun getSelectedCategories(): List<CategoryItem> = selectedCategories
return selectedCategories
}
/** /**
* Cleanup the existing in memory cache's * Cleanup the existing in memory cache's
*/ */
fun cleanUp() { fun cleanUp() {
selectedCategories.clear() selectedCategories.clear()
selectedExistingCategories.clear() selectedExistingCategories.clear()
} }
companion object { companion object {
const val SEARCH_CATS_LIMIT = 25 const val SEARCH_CATS_LIMIT = 25
} }
/** /**
* Provides selected existing categories * Provides selected existing categories
* *
* @return selected existing categories * @return selected existing categories
*/ */
fun getSelectedExistingCategories(): List<String> { fun getSelectedExistingCategories(): List<String> = selectedExistingCategories
return selectedExistingCategories
}
/** /**
* Initialize existing categories * Initialize existing categories
* *
* @param selectedExistingCategories existing categories * @param selectedExistingCategories existing categories
*/ */
fun setSelectedExistingCategories(selectedExistingCategories: MutableList<String>) { fun setSelectedExistingCategories(selectedExistingCategories: MutableList<String>) {
this.selectedExistingCategories = selectedExistingCategories this.selectedExistingCategories = selectedExistingCategories
}
} }
}

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.category package fr.free.nrw.commons.category
import io.reactivex.Single
import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse import fr.free.nrw.commons.wikidata.mwapi.MwQueryResponse
import io.reactivex.Single
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * Category Client to handle custom calls to Commons MediaWiki APIs
*/ */
@Singleton @Singleton
class CategoryClient @Inject constructor(private val categoryInterface: CategoryInterface) : class CategoryClient
ContinuationClient<MwQueryResponse, CategoryItem>() { @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. * Searches for categories starting with the specified string.
* *
* @param filter The string to be searched * @param prefix The prefix to be searched
* @param itemLimit How many results are returned * @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 * @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 * @return
*/ */
@JvmOverloads @JvmOverloads
fun searchCategories(filter: String?, itemLimit: Int, offset: Int = 0): fun searchCategoriesForPrefix(
Single<List<CategoryItem>> { prefix: String?,
return responseMapper(categoryInterface.searchCategories(filter, itemLimit, offset)) itemLimit: Int,
} offset: Int = 0,
): Single<List<CategoryItem>> =
/** responseMapper(
* Searches for categories starting with the specified string. categoryInterface.searchCategoriesForPrefix(prefix, itemLimit, offset),
*
* @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
) )
}
}
/** /**
* The method takes categoryName as input and returns a List of parent categories * Fetches categories starting and ending with a specified name.
* It uses the generator query API to get the parent categories of a category, 500 at a time. *
* * @param startingCategoryName Name of the category to start
* @param categoryName Category name as defined on commons * @param endingCategoryName Name of the category to end
* @return * @param itemLimit How many categories to return
*/ * @param offset offset
fun getParentCategoryList(categoryName: String): Single<List<CategoryItem>> { * @return MwQueryResponse
return continuationRequest(PARENT_CATEGORY_CONTINUATION_PREFIX, categoryName) { */
categoryInterface.getParentCategoryList(categoryName, it) @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) * 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.
*
fun resetParentCategoryContinuation(category: String) { * @param categoryName Category name as defined on commons
resetContinuation(PARENT_CATEGORY_CONTINUATION_PREFIX, category) * @return Observable emitting the categories returned. If our search yielded "Category:Test", "Test" is emitted.
} */
fun getSubCategoryList(categoryName: String): Single<List<CategoryItem>> =
override fun responseMapper( continuationRequest(SUB_CATEGORY_CONTINUATION_PREFIX, categoryName) {
networkResult: Single<MwQueryResponse>, categoryInterface.getSubCategoryList(
key: String? categoryName,
): Single<List<CategoryItem>> { it,
return networkResult )
.map {
handleContinuationResponse(it.continuation(), key)
it.query()?.pages() ?: emptyList()
} }
.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 { }.map {
CategoryItem(it.title().replace(CATEGORY_PREFIX, ""), it
it.description().toString(), it.thumbUrl().toString(), false) .filter { page ->
page.categoryInfo() == null || !page.categoryInfo().isHidden
}.map {
CategoryItem(
it.title().replace(CATEGORY_PREFIX, ""),
it.description().toString(),
it.thumbUrl().toString(),
false,
)
}
} }
}
} }
}

View file

@ -17,11 +17,13 @@ interface CategoryInterface {
* @param itemLimit How many results are returned * @param itemLimit How many results are returned
* @return * @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( fun searchCategories(
@Query("gsrsearch") filter: String?, @Query("gsrsearch") filter: String?,
@Query("gsrlimit") itemLimit: Int, @Query("gsrlimit") itemLimit: Int,
@Query("gsroffset") offset: Int @Query("gsroffset") offset: Int,
): Single<MwQueryResponse> ): Single<MwQueryResponse>
/** /**
@ -31,11 +33,13 @@ interface CategoryInterface {
* @param itemLimit How many results are returned * @param itemLimit How many results are returned
* @return * @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( fun searchCategoriesForPrefix(
@Query("gacprefix") prefix: String?, @Query("gacprefix") prefix: String?,
@Query("gaclimit") itemLimit: Int, @Query("gaclimit") itemLimit: Int,
@Query("gacoffset") offset: Int @Query("gacoffset") offset: Int,
): Single<MwQueryResponse> ): Single<MwQueryResponse>
/** /**
@ -47,23 +51,25 @@ interface CategoryInterface {
* @param offset offset * @param offset offset
* @return MwQueryResponse * @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( fun getCategoriesByName(
@Query("gacfrom") startingCategory: String?, @Query("gacfrom") startingCategory: String?,
@Query("gacto") endingCategory: String?, @Query("gacto") endingCategory: String?,
@Query("gaclimit") itemLimit: Int, @Query("gaclimit") itemLimit: Int,
@Query("gacoffset") offset: Int @Query("gacoffset") offset: Int,
): Single<MwQueryResponse> ): Single<MwQueryResponse>
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50") @GET("w/api.php?action=query&format=json&formatversion=2&generator=categorymembers&gcmtype=subcat&prop=info&gcmlimit=50")
fun getSubCategoryList( fun getSubCategoryList(
@Query("gcmtitle") categoryName: String, @Query("gcmtitle") categoryName: String,
@QueryMap(encoded = true) continuation: Map<String, String> @QueryMap(encoded = true) continuation: Map<String, String>,
): Single<MwQueryResponse> ): Single<MwQueryResponse>
@GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50") @GET("w/api.php?action=query&format=json&formatversion=2&generator=categories&prop=info&gcllimit=50")
fun getParentCategoryList( fun getParentCategoryList(
@Query("titles") categoryName: String?, @Query("titles") categoryName: String?,
@QueryMap(encoded = true) continuation: Map<String, String> @QueryMap(encoded = true) continuation: Map<String, String>,
): Single<MwQueryResponse> ): Single<MwQueryResponse>
} }

View file

@ -4,12 +4,13 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class CategoryItem(val name: String, val description: String?, data class CategoryItem(
val thumbnail: String?, var isSelected: Boolean) : Parcelable { val name: String,
val description: String?,
override fun toString(): String { val thumbnail: String?,
return "CategoryItem: '$name'" var isSelected: Boolean,
} ) : Parcelable {
override fun toString(): String = "CategoryItem: '$name'"
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@ -22,7 +23,5 @@ data class CategoryItem(val name: String, val description: String?,
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int = name.hashCode()
return name.hashCode()
}
} }

View file

@ -2,16 +2,16 @@ package fr.free.nrw.commons.category
import io.reactivex.Single import io.reactivex.Single
abstract class ContinuationClient<Network, Domain> { abstract class ContinuationClient<Network, Domain> {
private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf() private val continuationStore: MutableMap<String, Map<String, String>?> = mutableMapOf()
private val continuationExists: MutableMap<String, Boolean> = mutableMapOf() private val continuationExists: MutableMap<String, Boolean> = mutableMapOf()
private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true private fun hasMorePagesFor(key: String) = continuationExists[key] ?: true
fun continuationRequest( fun continuationRequest(
prefix: String, prefix: String,
name: String, name: String,
requestFunction: (Map<String, String>) -> Single<Network> requestFunction: (Map<String, String>) -> Single<Network>,
): Single<List<Domain>> { ): Single<List<Domain>> {
val key = "$prefix$name" val key = "$prefix$name"
return if (hasMorePagesFor(key)) { 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) { if (key != null) {
continuationExists[key] = continuationExists[key] =
continuation?.let { continuation -> 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") continuationExists.remove("$prefix$category")
continuationStore.remove("$prefix$category") continuationStore.remove("$prefix$category")
} }
@ -44,9 +53,11 @@ abstract class ContinuationClient<Network, Domain> {
* @param prefix * @param prefix
* @param userName the username * @param userName the username
*/ */
protected fun resetUserContinuation(prefix: String, userName: String) { protected fun resetUserContinuation(
prefix: String,
userName: String,
) {
continuationExists.remove("$prefix$userName") continuationExists.remove("$prefix$userName")
continuationStore.remove("$prefix$userName") continuationStore.remove("$prefix$userName")
} }
} }

View file

@ -7,32 +7,29 @@ import fr.free.nrw.commons.upload.UploadResult
data class ChunkInfo( data class ChunkInfo(
val uploadResult: UploadResult?, val uploadResult: UploadResult?,
val indexOfNextChunkToUpload: Int, val indexOfNextChunkToUpload: Int,
val totalChunks: Int val totalChunks: Int,
) : Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readParcelable(UploadResult::class.java.classLoader), parcel.readParcelable(UploadResult::class.java.classLoader),
parcel.readInt(), 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.writeParcelable(uploadResult, flags)
parcel.writeInt(indexOfNextChunkToUpload) parcel.writeInt(indexOfNextChunkToUpload)
parcel.writeInt(totalChunks) parcel.writeInt(totalChunks)
} }
override fun describeContents(): Int { override fun describeContents(): Int = 0
return 0
}
companion object CREATOR : Parcelable.Creator<ChunkInfo> { companion object CREATOR : Parcelable.Creator<ChunkInfo> {
override fun createFromParcel(parcel: Parcel): ChunkInfo { override fun createFromParcel(parcel: Parcel): ChunkInfo = ChunkInfo(parcel)
return ChunkInfo(parcel)
}
override fun newArray(size: Int): Array<ChunkInfo?> { override fun newArray(size: Int): Array<ChunkInfo?> = arrayOfNulls(size)
return arrayOfNulls(size)
}
} }
} }

View file

@ -5,7 +5,6 @@ import android.os.Parcelable
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.Media import fr.free.nrw.commons.Media
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.upload.UploadItem import fr.free.nrw.commons.upload.UploadItem
@ -31,8 +30,7 @@ data class Contribution constructor(
var errorInfo: String? = null, var errorInfo: String? = null,
/** /**
* @return array list of entityids for the depictions * @return array list of entityids for the depictions
*/ *
/**
* Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id * Each depiction loaded in depictions activity is associated with a wikidata entity id, this Id
* is in turn used to upload depictions to wikibase * is in turn used to upload depictions to wikibase
*/ */
@ -44,26 +42,23 @@ data class Contribution constructor(
var dateCreatedString: String? = null, var dateCreatedString: String? = null,
var dateModified: Date? = null, var dateModified: Date? = null,
var dateUploadStarted: Date? = null, var dateUploadStarted: Date? = null,
var hasInvalidLocation : Int = 0, var hasInvalidLocation: Int = 0,
var contentUri: Uri? = null, var contentUri: Uri? = null,
var countryCode : String? = null, var countryCode: String? = null,
var imageSHA1 : String? = null, var imageSHA1: String? = null,
/** /**
* Number of times a contribution has been retried after a failure * Number of times a contribution has been retried after a failure
*/ */
var retries: Int = 0 var retries: Int = 0,
) : Parcelable { ) : Parcelable {
fun completeWith(media: Media): Contribution = copy(pageId = media.pageId, media = media, state = STATE_COMPLETED)
fun completeWith(media: Media): Contribution {
return copy(pageId = media.pageId, media = media, state = STATE_COMPLETED)
}
constructor( constructor(
item: UploadItem, item: UploadItem,
sessionManager: SessionManager, sessionManager: SessionManager,
depictedItems: List<DepictedItem>, depictedItems: List<DepictedItem>,
categories: List<String>, categories: List<String>,
imageSHA1: String imageSHA1: String,
) : this( ) : this(
Media( Media(
formatCaptions(item.uploadMediaDetails), formatCaptions(item.uploadMediaDetails),
@ -71,7 +66,7 @@ data class Contribution constructor(
item.fileName, item.fileName,
formatDescriptions(item.uploadMediaDetails), formatDescriptions(item.uploadMediaDetails),
sessionManager.userName, sessionManager.userName,
sessionManager.userName sessionManager.userName,
), ),
localUri = item.mediaUri, localUri = item.mediaUri,
decimalCoords = item.gpsCoords.decimalCoords, decimalCoords = item.gpsCoords.decimalCoords,
@ -80,7 +75,7 @@ data class Contribution constructor(
wikidataPlace = from(item.place), wikidataPlace = from(item.place),
contentUri = item.contentUri, contentUri = item.contentUri,
dateCreatedString = item.fileCreatedDateString, dateCreatedString = item.fileCreatedDateString,
imageSHA1 = imageSHA1 imageSHA1 = imageSHA1,
) )
/** /**
@ -91,9 +86,7 @@ data class Contribution constructor(
this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0 this.hasInvalidLocation = if (hasInvalidLocation) 1 else 0
} }
fun hasInvalidLocation(): Boolean { fun hasInvalidLocation(): Boolean = hasInvalidLocation == 1
return hasInvalidLocation == 1
}
companion object { companion object {
const val STATE_COMPLETED = -1 const val STATE_COMPLETED = -1
@ -107,7 +100,8 @@ data class Contribution constructor(
* @param uploadMediaDetails list of media Details * @param uploadMediaDetails list of media Details
*/ */
fun formatCaptions(uploadMediaDetails: List<UploadMediaDetail>) = fun formatCaptions(uploadMediaDetails: List<UploadMediaDetail>) =
uploadMediaDetails.associate { it.languageCode!! to it.captionText } uploadMediaDetails
.associate { it.languageCode!! to it.captionText }
.filter { it.value.isNotBlank() } .filter { it.value.isNotBlank() }
/** /**
@ -117,19 +111,15 @@ data class Contribution constructor(
* @return a string with the pattern of {{en|1=descriptionText}} * @return a string with the pattern of {{en|1=descriptionText}}
*/ */
fun formatDescriptions(descriptions: List<UploadMediaDetail>) = fun formatDescriptions(descriptions: List<UploadMediaDetail>) =
descriptions.filter { it.descriptionText.isNotEmpty() } descriptions
.filter { it.descriptionText.isNotEmpty() }
.joinToString(separator = "") { "{{${it.languageCode}|1=${it.descriptionText}}}" } .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) } val localUriPath: File? get() = localUri?.path?.let { File(it) }
fun isCompleted(): Boolean { fun isCompleted(): Boolean = chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
return chunkInfo != null && chunkInfo!!.totalChunks == chunkInfo!!.indexOfNextChunkToUpload
}
fun dateUploadStartedInMillis(): Long {
return dateUploadStarted!!.time
}
fun dateUploadStartedInMillis(): Long = dateUploadStarted!!.time
} }

View file

@ -14,88 +14,90 @@ import javax.inject.Named
* Class that extends PagedList.BoundaryCallback for contributions list It defines the action that * Class that extends PagedList.BoundaryCallback for contributions list It defines the action that
* is triggered for various boundary conditions in the list * is triggered for various boundary conditions in the list
*/ */
class ContributionBoundaryCallback @Inject constructor( class ContributionBoundaryCallback
private val repository: ContributionsRepository, @Inject
private val sessionManager: SessionManager, constructor(
private val mediaClient: MediaClient, private val repository: ContributionsRepository,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler private val sessionManager: SessionManager,
) : BoundaryCallback<Contribution>() { private val mediaClient: MediaClient,
private val compositeDisposable: CompositeDisposable = CompositeDisposable() @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler,
var userName: String? = null ) : 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
* It is triggered when the list has no items User's Contributions are then fetched from the * network
* network */
*/ override fun onZeroItemsLoaded() {
override fun onZeroItemsLoaded() { if (sessionManager.userName != null) {
if (sessionManager.userName != null) { mediaClient.resetUserNameContinuation(sessionManager.userName!!)
mediaClient.resetUserNameContinuation(sessionManager.userName!!) }
fetchContributions()
} }
fetchContributions()
}
/** /**
* It is triggered when the user scrolls to the top of the list * It is triggered when the user scrolls to the top of the list
* */ * */
override fun onItemAtFrontLoaded(itemAtFront: Contribution) { 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 * Fetches contributions using the MediaWiki API
* fetched from the network */
*/ private fun fetchContributions() {
override fun onItemAtEndLoaded(itemAtEnd: Contribution) { if (sessionManager.userName != null) {
fetchContributions() userName
} ?.let { userName ->
mediaClient
/** .getMediaListForUser(userName)
* Fetches contributions using the MediaWiki API .map { mediaList ->
*/ mediaList.map { media ->
private fun fetchContributions() { Contribution(media = media, state = Contribution.STATE_COMPLETED)
if (sessionManager.userName != null) { }
userName?.let { userName -> }.subscribeOn(ioThreadScheduler)
mediaClient.getMediaListForUser(userName) .subscribe(::saveContributionsToDB) { error: Throwable ->
.map { mediaList -> Timber.e(
mediaList.map { media -> "Failed to fetch contributions: %s",
Contribution(media = media, state = Contribution.STATE_COMPLETED) error.message,
} )
} }
.subscribeOn(ioThreadScheduler) }?.let {
.subscribe(::saveContributionsToDB) { error: Throwable -> compositeDisposable.add(
Timber.e( it,
"Failed to fetch contributions: %s",
error.message
) )
} }
}?.let { } else {
compositeDisposable.add( compositeDisposable.clear()
it
)
} }
}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()
}
}

View file

@ -12,62 +12,61 @@ import javax.inject.Named
/** /**
* Data-Source which acts as mediator for contributions-data from the API * Data-Source which acts as mediator for contributions-data from the API
*/ */
class ContributionsRemoteDataSource @Inject constructor( class ContributionsRemoteDataSource
private val mediaClient: MediaClient, @Inject
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler constructor(
) : ItemKeyedDataSource<Int, Contribution>() { private val mediaClient: MediaClient,
private val compositeDisposable: CompositeDisposable = CompositeDisposable() @param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler,
var userName: String? = null ) : ItemKeyedDataSource<Int, Contribution>() {
private val compositeDisposable: CompositeDisposable = CompositeDisposable()
var userName: String? = null
override fun loadInitial( override fun loadInitial(
params: LoadInitialParams<Int>, params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Contribution> callback: LoadInitialCallback<Contribution>,
) { ) {
fetchContributions(callback) 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()
}
}

View file

@ -13,26 +13,30 @@ import fr.free.nrw.commons.databinding.DialogAddToWikipediaInstructionsBinding
* Dialog fragment for displaying instructions for editing wikipedia * Dialog fragment for displaying instructions for editing wikipedia
*/ */
class WikipediaInstructionsDialogFragment : DialogFragment() { class WikipediaInstructionsDialogFragment : DialogFragment() {
var callback: Callback? = null var callback: Callback? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
) = DialogAddToWikipediaInstructionsBinding.inflate(inflater, container, false).apply { ) = DialogAddToWikipediaInstructionsBinding
val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) .inflate(inflater, container, false)
tvWikicode.setText(contribution?.media?.wikiCode) .apply {
instructionsCancel.setOnClickListener { dismiss() } val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION)
instructionsConfirm.setOnClickListener { tvWikicode.setText(contribution?.media?.wikiCode)
callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked) instructionsCancel.setOnClickListener { dismiss() }
} instructionsConfirm.setOnClickListener {
}.root callback?.onConfirmClicked(contribution, checkboxCopyWikicode.isChecked)
}
}.root
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
dialog!!.window?.setSoftInputMode( 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 * Callback for handling confirm button clicked
*/ */
interface Callback { interface Callback {
fun onConfirmClicked(contribution: Contribution?, copyWikicode: Boolean) fun onConfirmClicked(
contribution: Contribution?,
copyWikicode: Boolean,
)
} }
companion object { companion object {
const val ARG_CONTRIBUTION = "contribution" const val ARG_CONTRIBUTION = "contribution"
@JvmStatic @JvmStatic
fun newInstance(contribution: Contribution) = WikipediaInstructionsDialogFragment().apply { fun newInstance(contribution: Contribution) =
arguments = bundleOf(ARG_CONTRIBUTION to contribution) WikipediaInstructionsDialogFragment().apply {
} arguments = bundleOf(ARG_CONTRIBUTION to contribution)
}
} }
} }

View file

@ -1,16 +1,16 @@
package fr.free.nrw.commons.customselector.database package fr.free.nrw.commons.customselector.database
import androidx.room.* import androidx.room.Entity
import androidx.room.PrimaryKey
/** /**
* Entity class for Not For Upload status. * Entity class for Not For Upload status.
*/ */
@Entity(tableName = "images_not_for_upload_table") @Entity(tableName = "images_not_for_upload_table")
data class NotForUploadStatus( data class NotForUploadStatus(
/** /**
* Original image sha1. * Original image sha1.
*/ */
@PrimaryKey @PrimaryKey
val imageSHA1 : String val imageSHA1: String,
) )

View file

@ -1,18 +1,20 @@
package fr.free.nrw.commons.customselector.database package fr.free.nrw.commons.customselector.database
import androidx.room.* import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
/** /**
* Dao class for Not For Upload * Dao class for Not For Upload
*/ */
@Dao @Dao
abstract class NotForUploadStatusDao { abstract class NotForUploadStatusDao {
/** /**
* Insert into Not For Upload status. * Insert into Not For Upload status.
*/ */
@Insert( onConflict = OnConflictStrategy.REPLACE ) @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(notForUploadStatus: NotForUploadStatus) abstract suspend fun insert(notForUploadStatus: NotForUploadStatus)
/** /**
@ -25,33 +27,27 @@ abstract class NotForUploadStatusDao {
* Query Not For Upload status with image sha1. * Query Not For Upload status with image sha1.
*/ */
@Query("SELECT * FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") @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. * Asynchronous image sha1 query.
*/ */
suspend fun getNotForUploadFromImageSHA1(imageSHA1: String):NotForUploadStatus? { suspend fun getNotForUploadFromImageSHA1(imageSHA1: String): NotForUploadStatus? = getFromImageSHA1(imageSHA1)
return getFromImageSHA1(imageSHA1)
}
/** /**
* Deletion Not For Upload status with image sha1. * Deletion Not For Upload status with image sha1.
*/ */
@Query("DELETE FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") @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. * Asynchronous image sha1 deletion.
*/ */
suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) { suspend fun deleteNotForUploadWithImageSHA1(imageSHA1: String) = deleteWithImageSHA1(imageSHA1)
return deleteWithImageSHA1(imageSHA1)
}
/** /**
* Check whether the imageSHA1 is present in database * Check whether the imageSHA1 is present in database
*/ */
@Query("SELECT COUNT() FROM images_not_for_upload_table WHERE imageSHA1 = (:imageSHA1) ") @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
} }

View file

@ -3,37 +3,32 @@ package fr.free.nrw.commons.customselector.database
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import java.util.* import java.util.Date
/** /**
* Entity class for Uploaded Status. * Entity class for Uploaded Status.
*/ */
@Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)]) @Entity(tableName = "uploaded_table", indices = [Index(value = ["modifiedImageSHA1"], unique = true)])
data class UploadedStatus( data class UploadedStatus(
/** /**
* Original image sha1. * Original image sha1.
*/ */
@PrimaryKey @PrimaryKey
val imageSHA1 : String, val imageSHA1: String,
/** /**
* Modified image sha1 (after exif changes). * Modified image sha1 (after exif changes).
*/ */
val modifiedImageSHA1 : String, val modifiedImageSHA1: String,
/** /**
* imageSHA1 query result from API. * imageSHA1 query result from API.
*/ */
var imageResult : Boolean, var imageResult: Boolean,
/** /**
* modifiedImageSHA1 query result from API. * modifiedImageSHA1 query result from API.
*/ */
var modifiedImageResult : Boolean, var modifiedImageResult: Boolean,
/** /**
* lastUpdated for data validation. * lastUpdated for data validation.
*/ */
var lastUpdated : Date? = null var lastUpdated: Date? = null,
) )

View file

@ -1,18 +1,22 @@
package fr.free.nrw.commons.customselector.database package fr.free.nrw.commons.customselector.database
import androidx.room.* import androidx.room.Dao
import java.util.* import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import java.util.Calendar
/** /**
* UploadedStatusDao for Custom Selector. * UploadedStatusDao for Custom Selector.
*/ */
@Dao @Dao
abstract class UploadedStatusDao { abstract class UploadedStatusDao {
/** /**
* Insert into uploaded status. * Insert into uploaded status.
*/ */
@Insert( onConflict = OnConflictStrategy.REPLACE ) @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(uploadedStatus: UploadedStatus) abstract suspend fun insert(uploadedStatus: UploadedStatus)
/** /**
@ -31,13 +35,13 @@ abstract class UploadedStatusDao {
* Query uploaded status with image sha1. * Query uploaded status with image sha1.
*/ */
@Query("SELECT * FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) ") @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 uploaded status with modified image sha1.
*/ */
@Query("SELECT * FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) ") @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. * Asynchronous insert into uploaded status table.
@ -51,20 +55,24 @@ abstract class UploadedStatusDao {
* Check whether the imageSHA1 is present in database * Check whether the imageSHA1 is present in database
*/ */
@Query("SELECT COUNT() FROM uploaded_table WHERE imageSHA1 = (:imageSHA1) AND imageResult = (:imageResult) ") @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 * Check whether the modifiedImageSHA1 is present in database
*/ */
@Query("SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ") @Query(
abstract suspend fun findByModifiedImageSHA1(modifiedImageSHA1 : String, "SELECT COUNT() FROM uploaded_table WHERE modifiedImageSHA1 = (:modifiedImageSHA1) AND modifiedImageResult = (:modifiedImageResult) ",
modifiedImageResult: Boolean): Int )
abstract suspend fun findByModifiedImageSHA1(
modifiedImageSHA1: String,
modifiedImageResult: Boolean,
): Int
/** /**
* Asynchronous image sha1 query. * Asynchronous image sha1 query.
*/ */
suspend fun getUploadedFromImageSHA1(imageSHA1: String):UploadedStatus? { suspend fun getUploadedFromImageSHA1(imageSHA1: String): UploadedStatus? = getFromImageSHA1(imageSHA1)
return getFromImageSHA1(imageSHA1) }
}
}

View file

@ -4,12 +4,10 @@ package fr.free.nrw.commons.customselector.helper
* Stores constants related to custom image selector * Stores constants related to custom image selector
*/ */
object CustomSelectorConstants { object CustomSelectorConstants {
const val BUCKET_ID = "bucket_id" const val BUCKET_ID = "bucket_id"
const val TOTAL_SELECTED_IMAGES = "total_selected_images" const val TOTAL_SELECTED_IMAGES = "total_selected_images"
const val PRESENT_POSITION = "present_position" const val PRESENT_POSITION = "present_position"
const val NEW_SELECTED_IMAGES = "new_selected_images" const val NEW_SELECTED_IMAGES = "new_selected_images"
const val SHOULD_REFRESH = "should_refresh" const val SHOULD_REFRESH = "should_refresh"
const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch" const val FULL_SCREEN_MODE_FIRST_LUNCH = "full_screen_mode_first_launch"
}
}

View file

@ -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. * Image Helper object, includes all the static functions and variables required by custom selector.
*/ */
object ImageHelper { object ImageHelper {
/** /**
* Custom selector preference key * Custom selector preference key
*/ */
@ -39,7 +38,10 @@ object ImageHelper {
/** /**
* Filters the images based on the given bucketId (folder) * 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 if (bukketId == null) return images
val filteredImages = arrayListOf<Image>() val filteredImages = arrayListOf<Image>()
@ -54,30 +56,37 @@ object ImageHelper {
/** /**
* getIndex: Returns the index of image in given list. * getIndex: Returns the index of image in given list.
*/ */
fun getIndex(list: ArrayList<Image>, image: Image): Int { fun getIndex(
return list.indexOf(image) list: ArrayList<Image>,
} image: Image,
): Int = list.indexOf(image)
/** /**
* getIndex: Returns the index of image in given list. * getIndex: Returns the index of image in given list.
*/ */
fun getIndexFromId(list: ArrayList<Image>, imageId: Long): Int { fun getIndexFromId(
for(i in list){ list: ArrayList<Image>,
if(i.id == imageId) imageId: Long,
): Int {
for (i in list) {
if (i.id == imageId) {
return list.indexOf(i) return list.indexOf(i)
}
} }
return 0; return 0
} }
/** /**
* Gets the list of indices from the master list. * Gets the list of indices from the master list.
*/ */
fun getIndexList(list: ArrayList<Image>, masterList: ArrayList<Image>): ArrayList<Int> { fun getIndexList(
list: ArrayList<Image>,
// Can be optimised as masterList is sorted by time. masterList: ArrayList<Image>,
): ArrayList<Int> {
// Can be optimised as masterList is sorted by time.
val indexes = arrayListOf<Int>() val indexes = arrayListOf<Int>()
for(image in list) { for (image in list) {
val index = getIndex(masterList, image) val index = getIndex(masterList, image)
if (index == -1) { if (index == -1) {
continue continue
@ -86,4 +95,4 @@ object ImageHelper {
} }
return indexes return indexes
} }
} }

View file

@ -2,23 +2,29 @@ package fr.free.nrw.commons.customselector.helper
import android.content.Context import android.content.Context
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.view.* import android.view.Display
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import kotlin.math.abs import kotlin.math.abs
/** /**
* Class for detecting swipe gestures * 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 gestureDetector: GestureDetector
private val SWIPE_THRESHOLD_HEIGHT = (getScreenResolution(context!!)).second / 3 private val swipeThresholdHeight = (getScreenResolution(context!!)).second / 3
private val SWIPE_THRESHOLD_WIDTH = (getScreenResolution(context!!)).first / 3 private val swipeThresholdWidth = (getScreenResolution(context!!)).first / 3
private val SWIPE_VELOCITY_THRESHOLD = 1000 private val swipeVelocityThreshold = 1000
override fun onTouch(view: View?, motionEvent: MotionEvent): Boolean { override fun onTouch(
return gestureDetector.onTouchEvent(motionEvent) view: View?,
} motionEvent: MotionEvent,
): Boolean = gestureDetector.onTouchEvent(motionEvent)
fun getScreenResolution(context: Context): Pair<Int, Int> { fun getScreenResolution(context: Context): Pair<Int, Int> {
val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val wm: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@ -31,10 +37,7 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
} }
inner class GestureListener : GestureDetector.SimpleOnGestureListener() { inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean = true
override fun onDown(e: MotionEvent): Boolean {
return true
}
/** /**
* Detects the gestures * Detects the gestures
@ -43,14 +46,16 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
event1: MotionEvent?, event1: MotionEvent?,
event2: MotionEvent, event2: MotionEvent,
velocityX: Float, velocityX: Float,
velocityY: Float velocityY: Float,
): Boolean { ): Boolean {
try { try {
val diffY: Float = event2.y - (event1?.y ?: event2.y) val diffY: Float = event2.y - (event1?.y ?: event2.y)
val diffX: Float = event2.x - (event1?.x ?: event2.x) val diffX: Float = event2.x - (event1?.x ?: event2.x)
if (abs(diffX) > abs(diffY)) { if (abs(diffX) > abs(diffY)) {
if (abs(diffX) > SWIPE_THRESHOLD_WIDTH && abs(velocityX) > if (abs(diffX) > swipeThresholdWidth &&
SWIPE_VELOCITY_THRESHOLD) { abs(velocityX) >
swipeVelocityThreshold
) {
if (diffX > 0) { if (diffX > 0) {
onSwipeRight() onSwipeRight()
} else { } else {
@ -58,8 +63,10 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
} }
} }
} else { } else {
if (abs(diffY) > SWIPE_THRESHOLD_HEIGHT && abs(velocityY) > if (abs(diffY) > swipeThresholdHeight &&
SWIPE_VELOCITY_THRESHOLD) { abs(velocityY) >
swipeVelocityThreshold
) {
if (diffY > 0) { if (diffY > 0) {
onSwipeDown() onSwipeDown()
} else { } else {
@ -100,4 +107,4 @@ open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
init { init {
gestureDetector = GestureDetector(context, GestureListener()) gestureDetector = GestureDetector(context, GestureListener())
} }
} }

View file

@ -4,12 +4,15 @@ package fr.free.nrw.commons.customselector.listeners
* Custom Selector Folder Click Listener * Custom Selector Folder Click Listener
*/ */
interface FolderClickListener { interface FolderClickListener {
/** /**
* onFolderClick * onFolderClick
* @param folderId : folder id of the folder. * @param folderId : folder id of the folder.
* @param folderName : folder name of the folder. * @param folderName : folder name of the folder.
* @param lastItemId : last scroll position in 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,
)
}

View file

@ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image
* responds to the device image query. * responds to the device image query.
*/ */
interface ImageLoaderListener { interface ImageLoaderListener {
/** /**
* On image loaded * On image loaded
* @param images : queried device images. * @param images : queried device images.
@ -19,4 +18,4 @@ interface ImageLoaderListener {
* @param throwable : throwable exception on failure. * @param throwable : throwable exception on failure.
*/ */
fun onFailed(throwable: Throwable) fun onFailed(throwable: Throwable)
} }

View file

@ -1,19 +1,20 @@
package fr.free.nrw.commons.customselector.listeners package fr.free.nrw.commons.customselector.listeners
import android.net.Uri
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.model.Image
/** /**
* Custom selector Image select listener * Custom selector Image select listener
*/ */
interface ImageSelectListener { interface ImageSelectListener {
/** /**
* onSelectedImagesChanged * onSelectedImagesChanged
* @param selectedImages : new selected images. * @param selectedImages : new selected images.
* @param selectedNotForUploadImages : number of selected not for upload 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 * onLongPress
@ -22,6 +23,6 @@ interface ImageSelectListener {
fun onLongPress( fun onLongPress(
position: Int, position: Int,
images: ArrayList<Image>, images: ArrayList<Image>,
selectedImages: ArrayList<Image> selectedImages: ArrayList<Image>,
) )
} }

View file

@ -6,5 +6,8 @@ import fr.free.nrw.commons.customselector.model.Image
* Interface to pass data between fragment and activity * Interface to pass data between fragment and activity
*/ */
interface PassDataListener { interface PassDataListener {
fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean) fun passSelectedImages(
} selectedImages: ArrayList<Image>,
shouldRefresh: Boolean,
)
}

View file

@ -8,4 +8,4 @@ interface RefreshUIListener {
* Refreshes the data in adapter * Refreshes the data in adapter
*/ */
fun refresh() fun refresh()
} }

View file

@ -6,17 +6,17 @@ package fr.free.nrw.commons.customselector.model
*/ */
sealed class CallbackStatus { sealed class CallbackStatus {
/** /**
IDLE : The callback is idle , doing nothing. IDLE : The callback is idle , doing nothing.
*/ */
object IDLE : CallbackStatus() object IDLE : CallbackStatus()
/** /**
FETCHING : Fetching images. FETCHING : Fetching images.
*/ */
object FETCHING : CallbackStatus() object FETCHING : CallbackStatus()
/** /**
SUCCESS : Success fetching images. SUCCESS : Success fetching images.
*/ */
object SUCCESS : CallbackStatus() object SUCCESS : CallbackStatus()
} }

View file

@ -5,27 +5,22 @@ package fr.free.nrw.commons.customselector.model
*/ */
data class Folder( data class Folder(
/** /**
bucketId : Unique directory id, eg 540528482 bucketId : Unique directory id, eg 540528482
*/ */
var bucketId: Long, var bucketId: Long,
/** /**
name : bucket/folder name, eg Camera name : bucket/folder name, eg Camera
*/ */
var name: String, 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. * Indicates whether some other object is "equal to" this one.
*/ */
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (javaClass != other?.javaClass) { if (javaClass != other?.javaClass) {
return false return false
} }
@ -44,4 +39,4 @@ data class Folder(
return true return true
} }
} }

View file

@ -9,65 +9,60 @@ import android.os.Parcelable
*/ */
data class Image( 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, 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, 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, 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, var path: String,
/** /**
bucketId : bucketId of folder, eg 540528482 bucketId : bucketId of folder, eg 540528482
*/ */
var bucketId: Long = 0, var bucketId: Long = 0,
/** /**
bucketName : name of folder, eg Camera bucketName : name of folder, eg Camera
*/ */
var bucketName: String = "", var bucketName: String = "",
/** /**
sha1 : sha1 of original image. sha1 : sha1 of original image.
*/ */
var sha1: String = "", var sha1: String = "",
/** /**
* date: Creation date of the image to show it inside the bubble during bubble scroll. * date: Creation date of the image to show it inside the bubble during bubble scroll.
*/ */
var date: String = "" var date: String = "",
) : Parcelable { ) : 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): override fun writeToParcel(
this(parcel.readLong(), parcel: Parcel,
parcel.readString()!!, flags: Int,
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) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(name) parcel.writeString(name)
parcel.writeParcelable(uri, flags) parcel.writeParcelable(uri, flags)
@ -81,41 +76,38 @@ data class Image(
/** /**
* Describe the kinds of special objects contained in this Parcelable * Describe the kinds of special objects contained in this Parcelable
*/ */
override fun describeContents(): Int { override fun describeContents(): Int = 0
return 0
}
/** /**
* Indicates whether some other object is "equal to" this one. * Indicates whether some other object is "equal to" this one.
*/ */
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (javaClass != other?.javaClass) {
if(javaClass != other?.javaClass) {
return false return false
} }
other as Image other as Image
if(id != other.id) { if (id != other.id) {
return false; return false
} }
if(name != other.name) { if (name != other.name) {
return false; return false
} }
if(uri != other.uri) { if (uri != other.uri) {
return false; return false
} }
if(path != other.path) { if (path != other.path) {
return false; return false
} }
if(bucketId != other.bucketId) { if (bucketId != other.bucketId) {
return false; return false
} }
if(bucketName != other.bucketName) { if (bucketName != other.bucketName) {
return false; return false
} }
if(sha1 != other.sha1) { if (sha1 != other.sha1) {
return false; return false
} }
return true return true
@ -125,12 +117,8 @@ data class Image(
* Parcelable companion object * Parcelable companion object
*/ */
companion object CREATOR : Parcelable.Creator<Image> { companion object CREATOR : Parcelable.Creator<Image> {
override fun createFromParcel(parcel: Parcel): Image { override fun createFromParcel(parcel: Parcel): Image = Image(parcel)
return Image(parcel)
}
override fun newArray(size: Int): Array<Image?> { override fun newArray(size: Int): Array<Image?> = arrayOfNulls(size)
return arrayOfNulls(size)
}
} }
} }

View file

@ -7,10 +7,9 @@ data class Result(
/** /**
* CallbackStatus : stores the result status * CallbackStatus : stores the result status
*/ */
val status:CallbackStatus, val status: CallbackStatus,
/** /**
* Images : images retrieved * Images : images retrieved
*/ */
val images: ArrayList<Image>) { val images: ArrayList<Image>,
} )

View file

@ -21,14 +21,11 @@ class FolderAdapter(
* Application context. * Application context.
*/ */
context: Context, context: Context,
/** /**
* Folder Click listener for click events. * Folder Click listener for click events.
*/ */
private val itemClickListener: FolderClickListener private val itemClickListener: FolderClickListener,
) : RecyclerViewAdapter<FolderAdapter.FolderViewHolder?>(context) { ) : RecyclerViewAdapter<FolderAdapter.FolderViewHolder?>(context) {
/** /**
* List of folders. * List of folders.
*/ */
@ -37,7 +34,10 @@ class FolderAdapter(
/** /**
* Create view holder, returns View holder item. * 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) val itemView = inflater.inflate(R.layout.item_custom_selector_folder, parent, false)
return FolderViewHolder(itemView) return FolderViewHolder(itemView)
} }
@ -45,28 +45,31 @@ class FolderAdapter(
/** /**
* Bind view holder, setup the item view, title, count and click listener * 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 folder = folders[position]
val toBeRemoved = ArrayList<Image>() val toBeRemoved = ArrayList<Image>()
for(image in folder.images) { for (image in folder.images) {
// Remove all the top images that do not exist anymore // 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 // File not found
toBeRemoved.add(image) toBeRemoved.add(image)
} else { } else {
break break
} }
} }
holder.image.setImageDrawable (null) holder.image.setImageDrawable(null)
folder.images.removeAll(toBeRemoved) folder.images.removeAll(toBeRemoved)
val count = folder.images.size 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. // Folder is empty, remove folder from the adapter.
holder.itemView.post{ holder.itemView.post {
val updatePosition = folders.indexOf(folder) val updatePosition = folders.indexOf(folder)
if(updatePosition != -1) { if (updatePosition != -1) {
folders.removeAt(updatePosition) folders.removeAt(updatePosition)
notifyItemRemoved(updatePosition) notifyItemRemoved(updatePosition)
notifyItemRangeChanged(updatePosition, folders.size) notifyItemRangeChanged(updatePosition, folders.size)
@ -89,9 +92,10 @@ class FolderAdapter(
fun init(newFolders: List<Folder>) { fun init(newFolders: List<Folder>) {
val oldFolderList: MutableList<Folder> = folders val oldFolderList: MutableList<Folder> = folders
val newFolderList = newFolders.toMutableList() val newFolderList = newFolders.toMutableList()
val diffResult = DiffUtil.calculateDiff( val diffResult =
FoldersDiffCallback(oldFolderList, newFolderList) DiffUtil.calculateDiff(
) FoldersDiffCallback(oldFolderList, newFolderList),
)
folders = newFolderList folders = newFolderList
diffResult.dispatchUpdatesTo(this) diffResult.dispatchUpdatesTo(this)
} }
@ -99,15 +103,14 @@ class FolderAdapter(
/** /**
* returns item count. * returns item count.
*/ */
override fun getItemCount(): Int { override fun getItemCount(): Int = folders.size
return folders.size
}
/** /**
* Folder view holder. * Folder view holder.
*/ */
class FolderViewHolder(itemView:View) : RecyclerView.ViewHolder(itemView) { class FolderViewHolder(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
/** /**
* Folder thumbnail image view. * Folder thumbnail image view.
*/ */
@ -129,37 +132,33 @@ class FolderAdapter(
*/ */
class FoldersDiffCallback( class FoldersDiffCallback(
var oldFolders: MutableList<Folder>, var oldFolders: MutableList<Folder>,
var newFolders: MutableList<Folder> var newFolders: MutableList<Folder>,
) : DiffUtil.Callback() { ) : DiffUtil.Callback() {
/** /**
* Returns the size of the old list. * Returns the size of the old list.
*/ */
override fun getOldListSize(): Int { override fun getOldListSize(): Int = oldFolders.size
return oldFolders.size
}
/** /**
* Returns the size of the new list. * Returns the size of the new list.
*/ */
override fun getNewListSize(): Int { override fun getNewListSize(): Int = newFolders.size
return newFolders.size
}
/** /**
* Called by the DiffUtil to decide whether two object represent the same Item. * Called by the DiffUtil to decide whether two object represent the same Item.
*/ */
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areItemsTheSame(
return oldFolders.get(oldItemPosition).bucketId == newFolders.get(newItemPosition).bucketId 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. * 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. * DiffUtil uses this information to detect if the contents of an item has changed.
*/ */
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areContentsTheSame(
return oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition)) oldItemPosition: Int,
} newItemPosition: Int,
): Boolean = oldFolders.get(oldItemPosition).equals(newFolders.get(newItemPosition))
} }
}
}

View file

@ -5,7 +5,6 @@ import android.content.SharedPreferences
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.constraintlayout.widget.Group import androidx.constraintlayout.widget.Group
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
@ -20,8 +19,13 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTION
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineDispatcher
import java.util.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.TreeMap
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
/** /**
@ -32,20 +36,16 @@ class ImageAdapter(
* Application Context. * Application Context.
*/ */
context: Context, context: Context,
/** /**
* Image select listener for click events on image. * Image select listener for click events on image.
*/ */
private var imageSelectListener: ImageSelectListener, private var imageSelectListener: ImageSelectListener,
/** /**
* ImageLoader queries images. * ImageLoader queries images.
*/ */
private var imageLoader: ImageLoader private var imageLoader: ImageLoader,
): ) : RecyclerViewAdapter<ImageAdapter.ImageViewHolder>(context),
FastScrollRecyclerView.SectionedAdapter {
RecyclerViewAdapter<ImageAdapter.ImageViewHolder>(context), FastScrollRecyclerView.SectionedAdapter {
/** /**
* ImageSelectedOrUpdated payload class. * ImageSelectedOrUpdated payload class.
*/ */
@ -106,14 +106,17 @@ class ImageAdapter(
/** /**
* Coroutine Dispatchers and Scope. * Coroutine Dispatchers and Scope.
*/ */
private var defaultDispatcher : CoroutineDispatcher = Dispatchers.Default private var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
private var ioDispatcher : CoroutineDispatcher = Dispatchers.IO private var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val scope : CoroutineScope = MainScope() private val scope: CoroutineScope = MainScope()
/** /**
* Create View holder. * 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) val itemView = inflater.inflate(R.layout.item_custom_selector_image, parent, false)
return ImageViewHolder(itemView) return ImageViewHolder(itemView)
} }
@ -121,10 +124,15 @@ class ImageAdapter(
/** /**
* Bind View holder, load image, selected view, click listeners. * Bind View holder, load image, selected view, click listeners.
*/ */
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { override fun onBindViewHolder(
if(images.size == 0) { return } holder: ImageViewHolder,
var image=images[position] position: Int,
holder.image.setImageDrawable (null) ) {
if (images.size == 0) {
return
}
var image = images[position]
holder.image.setImageDrawable(null)
if (context.contentResolver.getType(image.uri) == null) { if (context.contentResolver.getType(image.uri) == null) {
// Image does not exist anymore, update adapter. // Image does not exist anymore, update adapter.
holder.itemView.post { holder.itemView.post {
@ -140,18 +148,19 @@ class ImageAdapter(
sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
// Getting selected index when switch is on // Getting selected index when switch is on
val selectedIndex: Int = if (showAlreadyActionedImages) { val selectedIndex: Int =
ImageHelper.getIndex(selectedImages, image) if (showAlreadyActionedImages) {
ImageHelper.getIndex(selectedImages, image)
// Getting selected index when switch is off // Getting selected index when switch is off
} else if (actionableImagesMap.size > position) { } else if (actionableImagesMap.size > position) {
ImageHelper ImageHelper
.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) .getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position])
// For any other case return -1 // For any other case return -1
} else { } else {
-1 -1
} }
val isSelected = selectedIndex != -1 val isSelected = selectedIndex != -1
if (isSelected) { if (isSelected) {
@ -160,7 +169,11 @@ class ImageAdapter(
holder.itemUnselected() holder.itemUnselected()
} }
imageLoader.queryAndSetView( imageLoader.queryAndSetView(
holder, image, ioDispatcher, defaultDispatcher ,uploadingContributionList holder,
image,
ioDispatcher,
defaultDispatcher,
uploadingContributionList,
) )
scope.launch { scope.launch {
val sharedPreferences: SharedPreferences = val sharedPreferences: SharedPreferences =
@ -173,22 +186,28 @@ class ImageAdapter(
if (!alreadyAddedPositions.contains(position)) { if (!alreadyAddedPositions.contains(position)) {
processThumbnailForActionedImage(holder, position, uploadingContributionList) processThumbnailForActionedImage(holder, position, uploadingContributionList)
// If the position is already visited, that means the image is already present // 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 // inside map, so it will fetch the image from the map and load in the holder
} else { } else {
val actionableImages: List<Image> = ArrayList(actionableImagesMap.values) val actionableImages: List<Image> = ArrayList(actionableImagesMap.values)
if(actionableImages.size > position) { if (actionableImages.size > position) {
image = actionableImages[position] image = actionableImages[position]
Glide.with(holder.image).load(image.uri) Glide
.thumbnail(0.3f).into(holder.image) .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 // If switch is turned off, it just fetches the image from all images without any
// further operations // further operations
} else { } else {
Glide.with(holder.image).load(image.uri) Glide
.thumbnail(0.3f).into(holder.image) .with(holder.image)
.load(image.uri)
.thumbnail(0.3f)
.into(holder.image)
} }
} }
@ -210,12 +229,16 @@ class ImageAdapter(
suspend fun processThumbnailForActionedImage( suspend fun processThumbnailForActionedImage(
holder: ImageViewHolder, holder: ImageViewHolder,
position: Int, position: Int,
uploadingContributionList: List<Contribution> uploadingContributionList: List<Contribution>,
) { ) {
val next = imageLoader.nextActionableImage( val next =
allImages, ioDispatcher, defaultDispatcher, imageLoader.nextActionableImage(
nextImagePosition, uploadingContributionList allImages,
) ioDispatcher,
defaultDispatcher,
nextImagePosition,
uploadingContributionList,
)
// If next actionable image is found, saves it, as the the search for // If next actionable image is found, saves it, as the the search for
// finding next actionable image will start from this position // finding next actionable image will start from this position
@ -229,8 +252,11 @@ class ImageAdapter(
actionableImagesMap[next] = allImages[next] actionableImagesMap[next] = allImages[next]
alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder) alreadyAddedPositions.add(imagePositionAsPerIncreasingOrder)
imagePositionAsPerIncreasingOrder++ imagePositionAsPerIncreasingOrder++
Glide.with(holder.image).load(allImages[next].uri) Glide
.thumbnail(0.3f).into(holder.image) .with(holder.image)
.load(allImages[next].uri)
.thumbnail(0.3f)
.into(holder.image)
notifyItemInserted(position) notifyItemInserted(position)
notifyItemRangeChanged(position, itemCount + 1) notifyItemRangeChanged(position, itemCount + 1)
} }
@ -248,7 +274,7 @@ class ImageAdapter(
*/ */
private fun onThumbnailClicked( private fun onThumbnailClicked(
position: Int, position: Int,
holder: ImageViewHolder holder: ImageViewHolder,
) { ) {
val sharedPreferences: SharedPreferences = val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
@ -269,7 +295,10 @@ class ImageAdapter(
/** /**
* Handle click event on an image, update counter on images. * 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 = val sharedPreferences: SharedPreferences =
context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0) context.getSharedPreferences(CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val showAlreadyActionedImages = val showAlreadyActionedImages =
@ -277,14 +306,15 @@ class ImageAdapter(
// Getting clicked index from all images index when show_already_actioned_images // Getting clicked index from all images index when show_already_actioned_images
// switch is on // switch is on
val clickedIndex: Int = if(showAlreadyActionedImages) { val clickedIndex: Int =
ImageHelper.getIndex(selectedImages, images[position]) if (showAlreadyActionedImages) {
ImageHelper.getIndex(selectedImages, images[position])
// Getting clicked index from actionable images when show_already_actioned_images // Getting clicked index from actionable images when show_already_actioned_images
// switch is off // switch is off
} else { } else {
ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position]) ImageHelper.getIndex(selectedImages, ArrayList(actionableImagesMap.values)[position])
} }
if (clickedIndex != -1) { if (clickedIndex != -1) {
selectedImages.removeAt(clickedIndex) selectedImages.removeAt(clickedIndex)
@ -294,13 +324,14 @@ class ImageAdapter(
notifyItemChanged(position, ImageUnselected()) notifyItemChanged(position, ImageUnselected())
// Getting index from all images index when switch is on // Getting index from all images index when switch is on
val indexes = if (showAlreadyActionedImages) { val indexes =
ImageHelper.getIndexList(selectedImages, images) if (showAlreadyActionedImages) {
ImageHelper.getIndexList(selectedImages, images)
// Getting index from actionable images when switch is off // Getting index from actionable images when switch is off
} else { } else {
ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values))
} }
for (index in indexes) { for (index in indexes) {
notifyItemChanged(index, ImageSelectedOrUpdated()) notifyItemChanged(index, ImageSelectedOrUpdated())
} }
@ -313,15 +344,16 @@ class ImageAdapter(
} }
// Getting index from all images index when switch is on // Getting index from all images index when switch is on
val indexes: ArrayList<Int> = if (showAlreadyActionedImages) { val indexes: ArrayList<Int> =
selectedImages.add(images[position]) if (showAlreadyActionedImages) {
ImageHelper.getIndexList(selectedImages, images) selectedImages.add(images[position])
ImageHelper.getIndexList(selectedImages, images)
// Getting index from actionable images when switch is off // Getting index from actionable images when switch is off
} else { } else {
selectedImages.add(ArrayList(actionableImagesMap.values)[position]) selectedImages.add(ArrayList(actionableImagesMap.values)[position])
ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values)) ImageHelper.getIndexList(selectedImages, ArrayList(actionableImagesMap.values))
} }
for (index in indexes) { for (index in indexes) {
notifyItemChanged(index, ImageSelectedOrUpdated()) notifyItemChanged(index, ImageSelectedOrUpdated())
@ -334,10 +366,15 @@ class ImageAdapter(
/** /**
* Initialize the data set. * 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 allImages = fixedImages
val oldImageList:ArrayList<Image> = images val oldImageList: ArrayList<Image> = images
val newImageList:ArrayList<Image> = ArrayList(newImages) val newImageList: ArrayList<Image> = ArrayList(newImages)
actionableImagesMap = emptyMap actionableImagesMap = emptyMap
alreadyAddedPositions = ArrayList() alreadyAddedPositions = ArrayList()
uploadingContributionList = uploadedImages uploadingContributionList = uploadedImages
@ -345,9 +382,10 @@ class ImageAdapter(
reachedEndOfFolder = false reachedEndOfFolder = false
selectedImages = ArrayList() selectedImages = ArrayList()
imagePositionAsPerIncreasingOrder = 0 imagePositionAsPerIncreasingOrder = 0
val diffResult = DiffUtil.calculateDiff( val diffResult =
ImagesDiffCallback(oldImageList, newImageList) DiffUtil.calculateDiff(
) ImagesDiffCallback(oldImageList, newImageList),
)
images = newImageList images = newImageList
diffResult.dispatchUpdatesTo(this) diffResult.dispatchUpdatesTo(this)
} }
@ -355,31 +393,35 @@ class ImageAdapter(
/** /**
* Set new selected images * Set new selected images
*/ */
fun setSelectedImages(newSelectedImages: ArrayList<Image>){ fun setSelectedImages(newSelectedImages: ArrayList<Image>) {
selectedImages = ArrayList(newSelectedImages) selectedImages = ArrayList(newSelectedImages)
imageSelectListener.onSelectedImagesChanged(selectedImages, 0) imageSelectListener.onSelectedImagesChanged(selectedImages, 0)
} }
/** /**
* Refresh the data in the adapter * 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 numberOfSelectedImagesMarkedAsNotForUpload = 0
images.clear() images.clear()
selectedImages = arrayListOf() selectedImages = arrayListOf()
init(newImages, fixedImages, TreeMap(),uploadingImages) init(newImages, fixedImages, TreeMap(), uploadingImages)
notifyDataSetChanged() notifyDataSetChanged()
} }
/** /**
* Clear selected images and empty the list. * Clear selected images and empty the list.
*/ */
fun clearSelectedImages(){ fun clearSelectedImages() {
numberOfSelectedImagesMarkedAsNotForUpload = 0 numberOfSelectedImagesMarkedAsNotForUpload = 0
selectedImages.clear() selectedImages.clear()
selectedImages = arrayListOf() selectedImages = arrayListOf()
} }
/** /**
* Remove image from actionable images map. * Remove image from actionable images map.
*/ */
@ -389,7 +431,7 @@ class ImageAdapter(
val showAlreadyActionedImages = val showAlreadyActionedImages =
sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
if(showAlreadyActionedImages) { if (showAlreadyActionedImages) {
refresh(allImages, allImages, uploadingContributionList) refresh(allImages, allImages, uploadingContributionList)
} else { } else {
val iterator = actionableImagesMap.entries.iterator() val iterator = actionableImagesMap.entries.iterator()
@ -402,16 +444,14 @@ class ImageAdapter(
iterator.remove() iterator.remove()
alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1) alreadyAddedPositions.removeAt(alreadyAddedPositions.size - 1)
notifyItemRemoved(index) notifyItemRemoved(index)
notifyItemRangeChanged(index, itemCount ) notifyItemRangeChanged(index, itemCount)
break break
} }
index++ index++
} }
} }
} }
/** /**
* Returns the total number of items in the data set held by the adapter. * Returns the total number of items in the data set held by the adapter.
* *
@ -424,24 +464,22 @@ class ImageAdapter(
sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true) sharedPreferences.getBoolean(SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, true)
// While switch is on initializes the holder with all images size // While switch is on initializes the holder with all images size
return if(showAlreadyActionedImages) { return if (showAlreadyActionedImages) {
allImages.size allImages.size
// While switch is off and searching for next actionable has ended, initializes the holder // While switch is off and searching for next actionable has ended, initializes the holder
// with size of all actionable images // with size of all actionable images
} else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) { } else if (actionableImagesMap.size == allImages.size || reachedEndOfFolder) {
actionableImagesMap.size actionableImagesMap.size
// While switch is off, initializes the holder with and extra view holder so that finding // 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 // and addition of the next actionable image in the adapter can be continued
} else { } else {
actionableImagesMap.size + 1 actionableImagesMap.size + 1
} }
} }
fun getImageIdAt(position: Int): Long { fun getImageIdAt(position: Int): Long = images.get(position).id
return images.get(position).id
}
/** /**
* CleanUp function. * CleanUp function.
@ -453,7 +491,9 @@ class ImageAdapter(
/** /**
* Image view holder. * 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) val image: ImageView = itemView.findViewById(R.id.image_thumbnail)
private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group) private val uploadedGroup: Group = itemView.findViewById(R.id.uploaded_group)
private val uploadingGroup: Group = itemView.findViewById(R.id.uploading_group) private val uploadingGroup: Group = itemView.findViewById(R.id.uploading_group)
@ -495,16 +535,12 @@ class ImageAdapter(
notForUploadGroup.visibility = View.VISIBLE notForUploadGroup.visibility = View.VISIBLE
} }
fun isItemUploaded():Boolean { fun isItemUploaded(): Boolean = uploadedGroup.visibility == View.VISIBLE
return uploadedGroup.visibility == View.VISIBLE
}
/** /**
* Item is not for upload * Item is not for upload
*/ */
fun isItemNotForUpload():Boolean { fun isItemNotForUpload(): Boolean = notForUploadGroup.visibility == View.VISIBLE
return notForUploadGroup.visibility == View.VISIBLE
}
/** /**
* Item is not uploading * Item is not uploading
@ -533,45 +569,38 @@ class ImageAdapter(
*/ */
class ImagesDiffCallback( class ImagesDiffCallback(
var oldImageList: ArrayList<Image>, var oldImageList: ArrayList<Image>,
var newImageList: ArrayList<Image> var newImageList: ArrayList<Image>,
) : DiffUtil.Callback(){ ) : DiffUtil.Callback() {
/** /**
* Returns the size of the old list. * Returns the size of the old list.
*/ */
override fun getOldListSize(): Int { override fun getOldListSize(): Int = oldImageList.size
return oldImageList.size
}
/** /**
* Returns the size of the new list. * Returns the size of the new list.
*/ */
override fun getNewListSize(): Int { override fun getNewListSize(): Int = newImageList.size
return newImageList.size
}
/** /**
* Called by the DiffUtil to decide whether two object represent the same Item. * Called by the DiffUtil to decide whether two object represent the same Item.
*/ */
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areItemsTheSame(
return newImageList[newItemPosition].id == oldImageList[oldItemPosition].id 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. * 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. * DiffUtil uses this information to detect if the contents of an item has changed.
*/ */
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areContentsTheSame(
return oldImageList[oldItemPosition].equals(newImageList[newItemPosition]) oldItemPosition: Int,
} newItemPosition: Int,
): Boolean = oldImageList[oldItemPosition].equals(newImageList[newItemPosition])
} }
/** /**
* Returns the text for showing inside the bubble during bubble scroll. * Returns the text for showing inside the bubble during bubble scroll.
*/ */
override fun getSectionName(position: Int): String { override fun getSectionName(position: Int): String = images[position].date
return images[position].date
}
} }

View file

@ -7,6 +7,8 @@ import androidx.recyclerview.widget.RecyclerView
/** /**
* Generic Recycler view adapter. * 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) val inflater: LayoutInflater = LayoutInflater.from(context)
} }

View file

@ -8,23 +8,16 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.View import android.view.View
import android.view.Window import android.view.Window
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.compose.foundation.BorderStroke 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -38,7 +31,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -57,23 +49,27 @@ import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding
import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding
import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding
import fr.free.nrw.commons.filepicker.Constants 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.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.PermissionUtils import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.lang.Integer.max import java.lang.Integer.max
import javax.inject.Inject import javax.inject.Inject
/** /**
* Custom Selector Activity. * Custom Selector Activity.
*/ */
class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectListener { class CustomSelectorActivity :
BaseActivity(),
FolderClickListener,
ImageSelectListener {
/** /**
* ViewBindings * ViewBindings
*/ */
@ -147,7 +143,7 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/ */
var imageFragment: ImageFragment? = null var imageFragment: ImageFragment? = null
private var progressDialogText:String="" private var progressDialogText: String = ""
private var showPartialAccessIndicator by mutableStateOf(false) private var showPartialAccessIndicator by mutableStateOf(false)
@ -158,7 +154,8 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_MEDIA_IMAGES this,
Manifest.permission.READ_MEDIA_IMAGES,
) == PackageManager.PERMISSION_DENIED ) == PackageManager.PERMISSION_DENIED
) { ) {
showPartialAccessIndicator = true showPartialAccessIndicator = true
@ -168,25 +165,27 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root)
bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root)
binding.partialAccessIndicator.setContent { binding.partialAccessIndicator.setContent {
PartialStorageAccessIndicator( partialStorageAccessIndicator(
isVisible = showPartialAccessIndicator, isVisible = showPartialAccessIndicator,
onManage = { 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) requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1)
} }
}, },
modifier = Modifier modifier =
.padding(vertical = 8.dp, horizontal = 4.dp) Modifier
.fillMaxWidth() .padding(vertical = 8.dp, horizontal = 4.dp)
.fillMaxWidth(),
) )
} }
val view = binding.root val view = binding.root
setContentView(view) setContentView(view)
prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE)
viewModel = ViewModelProvider(this, customSelectorViewModelFactory).get( viewModel =
CustomSelectorViewModel::class.java ViewModelProvider(this, customSelectorViewModelFactory).get(
) CustomSelectorViewModel::class.java,
)
setupViews() setupViews()
@ -208,11 +207,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,
grantResults: IntArray grantResults: IntArray,
) { ) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if(requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (requestCode == 1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showPartialAccessIndicator = false showPartialAccessIndicator = false
} }
} }
@ -226,7 +225,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
/** /**
* When data will be send from full screen mode, it will be passed to fragment * 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) super.onActivityResult(requestCode, resultCode, data)
if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE && if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE &&
resultCode == Activity.RESULT_OK resultCode == Activity.RESULT_OK
@ -254,7 +257,8 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
* Set up view, default folder view. * Set up view, default folder view.
*/ */
private fun setupViews() { private fun setupViews() {
supportFragmentManager.beginTransaction() supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance()) .replace(R.id.fragment_container, FolderFragment.newInstance())
.commit() .commit()
setUpToolbar() setUpToolbar()
@ -322,12 +326,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
var allImagesAlreadyNotForUpload = true var allImagesAlreadyNotForUpload = true
images.forEach { image -> images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1( val imageSHA1 =
image.uri, CustomSelectorUtils.getImageSHA1(
ioDispatcher, image.uri,
fileUtilsWrapper, ioDispatcher,
contentResolver fileUtilsWrapper,
) contentResolver,
)
val exists = notForUploadStatusDao.find(imageSHA1) val exists = notForUploadStatusDao.find(imageSHA1)
if (exists < 1) { if (exists < 1) {
allImagesAlreadyNotForUpload = false allImagesAlreadyNotForUpload = false
@ -337,12 +342,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
if (!allImagesAlreadyNotForUpload) { if (!allImagesAlreadyNotForUpload) {
// Insert or delete images as necessary, but the UI updates should be posted back to the main thread // Insert or delete images as necessary, but the UI updates should be posted back to the main thread
images.forEach { image -> images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1( val imageSHA1 =
image.uri, CustomSelectorUtils.getImageSHA1(
ioDispatcher, image.uri,
fileUtilsWrapper, ioDispatcher,
contentResolver fileUtilsWrapper,
) contentResolver,
)
notForUploadStatusDao.insert(NotForUploadStatus(imageSHA1)) notForUploadStatusDao.insert(NotForUploadStatus(imageSHA1))
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -353,12 +359,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
} }
} else { } else {
images.forEach { image -> images.forEach { image ->
val imageSHA1 = CustomSelectorUtils.getImageSHA1( val imageSHA1 =
image.uri, CustomSelectorUtils.getImageSHA1(
ioDispatcher, image.uri,
fileUtilsWrapper, ioDispatcher,
contentResolver fileUtilsWrapper,
) contentResolver,
)
notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1) notForUploadStatusDao.deleteNotForUploadWithImageSHA1(imageSHA1)
} }
@ -386,13 +393,19 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
/** /**
* Change the title of the toolbar. * Change the title of the toolbar.
*/ */
private fun changeTitle(title: String, selectedImageCount:Int) { private fun changeTitle(
if (title.isNotEmpty()){ title: String,
selectedImageCount: Int,
) {
if (title.isNotEmpty()) {
val titleText = findViewById<TextView>(R.id.title) val titleText = findViewById<TextView>(R.id.title)
var titleWithAppendedImageCount = title var titleWithAppendedImageCount = title
if (selectedImageCount > 0) { if (selectedImageCount > 0) {
titleWithAppendedImageCount += " (${resources.getQuantityString(R.plurals.custom_picker_images_selected_title_appendix, titleWithAppendedImageCount += " (${resources.getQuantityString(
selectedImageCount, selectedImageCount)})" R.plurals.custom_picker_images_selected_title_appendix,
selectedImageCount,
selectedImageCount,
)})"
} }
if (titleText != null) { if (titleText != null) {
titleText.text = titleWithAppendedImageCount titleText.text = titleWithAppendedImageCount
@ -415,8 +428,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
/** /**
* override on folder click, change the toolbar title on folder click. * override on folder click, change the toolbar title on folder click.
*/ */
override fun onFolderClick(folderId: Long, folderName: String, lastItemId: Long) { override fun onFolderClick(
supportFragmentManager.beginTransaction() folderId: Long,
folderName: String,
lastItemId: Long,
) {
supportFragmentManager
.beginTransaction()
.add(R.id.fragment_container, ImageFragment.newInstance(folderId, lastItemId)) .add(R.id.fragment_container, ImageFragment.newInstance(folderId, lastItemId))
.addToBackStack(null) .addToBackStack(null)
.commit() .commit()
@ -433,18 +451,21 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/ */
override fun onSelectedImagesChanged( override fun onSelectedImagesChanged(
selectedImages: ArrayList<Image>, selectedImages: ArrayList<Image>,
selectedNotForUploadImages: Int selectedNotForUploadImages: Int,
) { ) {
viewModel.selectedImages.value = selectedImages viewModel.selectedImages.value = selectedImages
changeTitle(bucketName, selectedImages.size) changeTitle(bucketName, selectedImages.size)
uploadLimitExceeded = selectedImages.size > uploadLimit uploadLimitExceeded = selectedImages.size > uploadLimit
uploadLimitExceededBy = max(selectedImages.size - uploadLimit,0) uploadLimitExceededBy = max(selectedImages.size - uploadLimit, 0)
if (uploadLimitExceeded && selectedNotForUploadImages == 0) { if (uploadLimitExceeded && selectedNotForUploadImages == 0) {
toolbarBinding.imageLimitError.visibility = View.VISIBLE toolbarBinding.imageLimitError.visibility = View.VISIBLE
bottomSheetBinding.upload.text = resources.getString( bottomSheetBinding.upload.text =
R.string.custom_selector_button_limit_text, uploadLimit) resources.getString(
R.string.custom_selector_button_limit_text,
uploadLimit,
)
} else { } else {
toolbarBinding.imageLimitError.visibility = View.INVISIBLE toolbarBinding.imageLimitError.visibility = View.INVISIBLE
bottomSheetBinding.upload.text = resources.getString(R.string.upload) bottomSheetBinding.upload.text = resources.getString(R.string.upload)
@ -461,11 +482,11 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
bottomSheetBinding.notForUpload.text = bottomSheetBinding.notForUpload.text =
when (selectedImages.size == selectedNotForUploadImages) { when (selectedImages.size == selectedNotForUploadImages) {
true -> { 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) getString(R.string.unmark_as_not_for_upload)
} }
else -> { 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) getString(R.string.mark_as_not_for_upload)
} }
} }
@ -481,13 +502,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
override fun onLongPress( override fun onLongPress(
position: Int, position: Int,
images: ArrayList<Image>, images: ArrayList<Image>,
selectedImages: ArrayList<Image> selectedImages: ArrayList<Image>,
) { ) {
val intent = Intent(this, ZoomableActivity::class.java) val intent = Intent(this, ZoomableActivity::class.java)
intent.putExtra(CustomSelectorConstants.PRESENT_POSITION, position) intent.putExtra(CustomSelectorConstants.PRESENT_POSITION, position)
intent.putParcelableArrayListExtra( intent.putParcelableArrayListExtra(
CustomSelectorConstants.TOTAL_SELECTED_IMAGES, CustomSelectorConstants.TOTAL_SELECTED_IMAGES,
selectedImages selectedImages,
) )
intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId) intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId)
startActivityForResult(intent, Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE) startActivityForResult(intent, Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE)
@ -498,22 +519,22 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
* Get the selected images. Remove any non existent file, forward the data to finish selector. * Get the selected images. Remove any non existent file, forward the data to finish selector.
*/ */
fun onDone() { fun onDone() {
val selectedImages = viewModel.selectedImages.value val selectedImages = viewModel.selectedImages.value
if (selectedImages.isNullOrEmpty()) { if (selectedImages.isNullOrEmpty()) {
finishPickImages(arrayListOf()) finishPickImages(arrayListOf())
return 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 i++
while (i < selectedImages.size) { }
val path = selectedImages[i].path finishPickImages(selectedImages)
val file = File(path)
if (!file.exists()) {
selectedImages.removeAt(i)
i--
}
i++
}
finishPickImages(selectedImages)
} }
/** /**
@ -547,10 +568,13 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
val dialog = Dialog(this) val dialog = Dialog(this)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setContentView(R.layout.custom_selector_limit_dialog) dialog.setContentView(R.layout.custom_selector_limit_dialog)
(dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener() (dialog.findViewById(R.id.btn_dismiss_limit_warning) as Button).setOnClickListener { dialog.dismiss() }
{ dialog.dismiss() } (dialog.findViewById(R.id.upload_limit_warning) as TextView).text =
(dialog.findViewById(R.id.upload_limit_warning) as TextView).text = resources.getString( resources.getString(
R.string.custom_selector_over_limit_warning, uploadLimit, uploadLimitExceededBy) R.string.custom_selector_over_limit_warning,
uploadLimit,
uploadLimitExceededBy,
)
dialog.show() dialog.show()
} }
@ -560,9 +584,17 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
*/ */
override fun onDestroy() { override fun onDestroy() {
if (isImageFragmentOpen) { 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 { } else {
prefs.edit().remove(FOLDER_ID).remove(FOLDER_NAME).apply() prefs
.edit()
.remove(FOLDER_ID)
.remove(FOLDER_NAME)
.apply()
} }
super.onDestroy() super.onDestroy()
} }
@ -573,38 +605,41 @@ class CustomSelectorActivity : BaseActivity(), FolderClickListener, ImageSelectL
const val ITEM_ID: String = "ItemId" const val ITEM_ID: String = "ItemId"
} }
} }
@Composable @Composable
fun PartialStorageAccessIndicator( fun partialStorageAccessIndicator(
isVisible: Boolean, isVisible: Boolean,
onManage: ()-> Unit, onManage: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
if(isVisible) { if (isVisible) {
OutlinedCard( OutlinedCard(
modifier = modifier, modifier = modifier,
colors = CardDefaults.cardColors( colors =
containerColor = colorResource(R.color.primarySuperLightColor) CardDefaults.cardColors(
), containerColor = colorResource(R.color.primarySuperLightColor),
),
border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)), 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()) { Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
Text( Text(
text = "You've given access to a select number of photos", text = "You've given access to a select number of photos",
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
TextButton( TextButton(
onClick = onManage, onClick = onManage,
modifier = Modifier.align(Alignment.Bottom), modifier = Modifier.align(Alignment.Bottom),
colors = ButtonDefaults.buttonColors( colors =
containerColor = colorResource(R.color.primaryColor) ButtonDefaults.buttonColors(
), containerColor = colorResource(R.color.primaryColor),
shape = RoundedCornerShape(8.dp) ),
shape = RoundedCornerShape(8.dp),
) { ) {
Text( Text(
text = "Manage", text = "Manage",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = colorResource(R.color.primaryTextColor) color = colorResource(R.color.primaryTextColor),
) )
} }
} }
@ -614,11 +649,15 @@ fun PartialStorageAccessIndicator(
@Preview @Preview
@Composable @Composable
fun PartialStorageAccessIndicatorPreview() { fun partialStorageAccessIndicatorPreview() {
Surface { Surface {
PartialStorageAccessIndicator(isVisible = true, onManage = {}, modifier = Modifier partialStorageAccessIndicator(
.padding(vertical = 8.dp, horizontal = 4.dp) isVisible = true,
.fillMaxWidth() onManage = {},
modifier =
Modifier
.padding(vertical = 8.dp, horizontal = 4.dp)
.fillMaxWidth(),
) )
} }
} }

View file

@ -14,8 +14,10 @@ import kotlinx.coroutines.cancel
/** /**
* Custom Selector view model. * 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). * Scope for coroutine task (image fetch).
*/ */
@ -37,15 +39,17 @@ class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFil
fun fetchImages() { fun fetchImages() {
result.postValue(Result(CallbackStatus.FETCHING, arrayListOf())) result.postValue(Result(CallbackStatus.FETCHING, arrayListOf()))
scope.cancel() scope.cancel()
imageFileLoader.loadDeviceImages(object: ImageLoaderListener { imageFileLoader.loadDeviceImages(
override fun onImageLoaded(images: ArrayList<Image>) { object : ImageLoaderListener {
result.postValue(Result(CallbackStatus.SUCCESS, images)) override fun onImageLoaded(images: ArrayList<Image>) {
} result.postValue(Result(CallbackStatus.SUCCESS, images))
}
override fun onFailed(throwable: Throwable) { override fun onFailed(throwable: Throwable) {
result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf())) result.postValue(Result(CallbackStatus.SUCCESS, arrayListOf()))
} }
}) },
)
} }
/** /**
@ -55,4 +59,4 @@ class CustomSelectorViewModel(var context: Context,var imageFileLoader: ImageFil
scope.cancel() scope.cancel()
super.onCleared() super.onCleared()
} }
} }

View file

@ -8,10 +8,12 @@ import javax.inject.Inject
/** /**
* View Model Factory. * View Model Factory.
*/ */
class CustomSelectorViewModelFactory @Inject constructor(val context: Context,val imageFileLoader: ImageFileLoader) : ViewModelProvider.Factory { class CustomSelectorViewModelFactory
@Inject
override fun<CustomSelectorViewModel: ViewModel> create(modelClass: Class<CustomSelectorViewModel>) : CustomSelectorViewModel { constructor(
return CustomSelectorViewModel(context,imageFileLoader) as CustomSelectorViewModel val context: Context,
val imageFileLoader: ImageFileLoader,
) : ViewModelProvider.Factory {
override fun <CustomSelectorViewModel : ViewModel> create(modelClass: Class<CustomSelectorViewModel>): CustomSelectorViewModel =
CustomSelectorViewModel(context, imageFileLoader) as CustomSelectorViewModel
} }
}

View file

@ -9,10 +9,10 @@ import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.customselector.helper.ImageHelper 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.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Folder 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.customselector.ui.adapter.FolderAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
@ -24,12 +24,11 @@ import javax.inject.Inject
* Custom selector folder fragment. * Custom selector folder fragment.
*/ */
class FolderFragment : CommonsDaggerSupportFragment() { class FolderFragment : CommonsDaggerSupportFragment() {
/** /**
* ViewBinding * ViewBinding
*/ */
private var _binding: FragmentCustomSelectorBinding? = null private var _binding: FragmentCustomSelectorBinding? = null
private val binding get() = _binding val binding get() = _binding
/** /**
* View Model for images. * View Model for images.
@ -53,6 +52,7 @@ class FolderFragment : CommonsDaggerSupportFragment() {
var mediaClient: MediaClient? = null var mediaClient: MediaClient? = null
@Inject set @Inject set
/** /**
* Folder Adapter. * Folder Adapter.
*/ */
@ -66,15 +66,13 @@ class FolderFragment : CommonsDaggerSupportFragment() {
/** /**
* Folder List. * Folder List.
*/ */
private lateinit var folders : ArrayList<Folder> private lateinit var folders: ArrayList<Folder>
/** /**
* Companion newInstance. * Companion newInstance.
*/ */
companion object{ companion object {
fun newInstance(): FolderFragment { fun newInstance(): FolderFragment = FolderFragment()
return FolderFragment()
}
} }
/** /**
@ -83,21 +81,24 @@ class FolderFragment : CommonsDaggerSupportFragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(requireActivity(),customSelectorViewModelFactory!!).get(CustomSelectorViewModel::class.java) viewModel = ViewModelProvider(requireActivity(), customSelectorViewModelFactory!!).get(CustomSelectorViewModel::class.java)
} }
/** /**
* OnCreateView. * OnCreateView.
* Inflate Layout, init adapter, init gridLayoutManager, setUp recycler view, observe the view model for result. * 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) _binding = FragmentCustomSelectorBinding.inflate(inflater, container, false)
folderAdapter = FolderAdapter(requireActivity(), activity as FolderClickListener) folderAdapter = FolderAdapter(requireActivity(), activity as FolderClickListener)
gridLayoutManager = GridLayoutManager(context, columnCount()) gridLayoutManager = GridLayoutManager(context, columnCount())
selectorRV = binding?.selectorRv selectorRV = binding?.selectorRv
loader = binding?.loader loader = binding?.loader
with(binding?.selectorRv){ with(binding?.selectorRv) {
this?.layoutManager = gridLayoutManager this?.layoutManager = gridLayoutManager
this?.setHasFixedSize(true) this?.setHasFixedSize(true)
this?.adapter = folderAdapter this?.adapter = folderAdapter
@ -114,9 +115,9 @@ class FolderFragment : CommonsDaggerSupportFragment() {
* Load adapter. * Load adapter.
*/ */
private fun handleResult(result: Result) { private fun handleResult(result: Result) {
if(result.status is CallbackStatus.SUCCESS){ if (result.status is CallbackStatus.SUCCESS) {
val images = result.images val images = result.images
if(images.isEmpty()){ if (images.isEmpty()) {
binding?.emptyText?.let { binding?.emptyText?.let {
it.visibility = View.VISIBLE it.visibility = View.VISIBLE
} }

View file

@ -20,8 +20,9 @@ import kotlin.coroutines.CoroutineContext
* Custom Selector Image File Loader. * Custom Selector Image File Loader.
* Loads device images. * Loads device images.
*/ */
class ImageFileLoader(val context: Context) : CoroutineScope{ class ImageFileLoader(
val context: Context,
) : CoroutineScope {
/** /**
* Coroutine context for fetching images. * Coroutine context for fetching images.
*/ */
@ -30,14 +31,15 @@ class ImageFileLoader(val context: Context) : CoroutineScope{
/** /**
* Media paramerters required. * Media paramerters required.
*/ */
private val projection = arrayOf( private val projection =
MediaStore.Images.Media._ID, arrayOf(
MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.BUCKET_ID, MediaStore.Images.Media.DATA,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME, MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.DATE_ADDED MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
) MediaStore.Images.Media.DATE_ADDED,
)
/** /**
* Load Device Images under coroutine. * Load Device Images under coroutine.
@ -50,12 +52,18 @@ class ImageFileLoader(val context: Context) : CoroutineScope{
} }
} }
/** /**
* Load Device images using cursor * Load Device images using cursor
*/ */
private fun getImages(listener:ImageLoaderListener) { 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") val cursor =
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
MediaStore.Images.Media.DATE_ADDED + " DESC",
)
if (cursor == null) { if (cursor == null) {
listener.onFailed(NullPointerException()) listener.onFailed(NullPointerException())
return return
@ -85,10 +93,12 @@ class ImageFileLoader(val context: Context) : CoroutineScope{
val file = val file =
if (path == null || path.isEmpty()) { if (path == null || path.isEmpty()) {
null null
} else try { } else {
File(path) try {
} catch (ignored: Exception) { File(path)
null } catch (ignored: Exception) {
null
}
} }
if (file != null && file.exists() && name != null && path != null && bucketName != 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 dateFormat = DateFormat.getMediumDateFormat(context)
val formattedDate = dateFormat.format(date) val formattedDate = dateFormat.format(date)
val image = Image( val image =
id, Image(
name, id,
uri, name,
path, uri,
bucketId, path,
bucketName, bucketId,
date = (formattedDate) bucketName,
) date = (formattedDate),
)
images.add(image) images.add(image)
} }
} while (cursor.moveToNext()) } while (cursor.moveToNext())
} }
cursor.close() cursor.close()
listener.onImageLoaded(images) listener.onImageLoaded(images)
} }
/** /**
* Abort loading images. * Abort loading images.
*/ */
fun abortLoadImage(){ fun abortLoadImage() {
//todo Abort loading images. // todo Abort loading images.
} }
/* /*

View file

@ -37,17 +37,19 @@ import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileProcessor import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import java.util.* import java.util.TreeMap
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
/** /**
* Custom Selector Image Fragment. * Custom Selector Image Fragment.
*/ */
class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDataListener { class ImageFragment :
CommonsDaggerSupportFragment(),
RefreshUIListener,
PassDataListener {
private var _binding: FragmentCustomSelectorBinding? = null private var _binding: FragmentCustomSelectorBinding? = null
private val binding get() = _binding val binding get() = _binding
/** /**
* Current bucketId. * Current bucketId.
@ -107,7 +109,6 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
private lateinit var progressDialog: AlertDialog private lateinit var progressDialog: AlertDialog
private lateinit var progressDialogLayout: ProgressDialogBinding private lateinit var progressDialogLayout: ProgressDialogBinding
/** /**
* NotForUploadStatus Dao class for database operations * NotForUploadStatus Dao class for database operations
*/ */
@ -142,7 +143,6 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
lateinit var contributionDao: ContributionDao lateinit var contributionDao: ContributionDao
companion object { companion object {
/** /**
* Switch state * Switch state
*/ */
@ -157,7 +157,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
/** /**
* newInstance from bucketId. * newInstance from bucketId.
*/ */
fun newInstance(bucketId: Long, lastItemId: Long): ImageFragment { fun newInstance(
bucketId: Long,
lastItemId: Long,
): ImageFragment {
val fragment = ImageFragment() val fragment = ImageFragment()
val args = Bundle() val args = Bundle()
args.putLong(BUCKET_ID, bucketId) args.putLong(BUCKET_ID, bucketId)
@ -175,9 +178,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bucketId = arguments?.getLong(BUCKET_ID) bucketId = arguments?.getLong(BUCKET_ID)
lastItemId = arguments?.getLong(LAST_ITEM_ID, 0) lastItemId = arguments?.getLong(LAST_ITEM_ID, 0)
viewModel = ViewModelProvider(requireActivity(), customSelectorViewModelFactory).get( viewModel =
CustomSelectorViewModel::class.java ViewModelProvider(requireActivity(), customSelectorViewModelFactory).get(
) CustomSelectorViewModel::class.java,
)
} }
/** /**
@ -188,7 +192,7 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?,
): View? { ): View? {
_binding = FragmentCustomSelectorBinding.inflate(inflater, container, false) _binding = FragmentCustomSelectorBinding.inflate(inflater, container, false)
imageAdapter = imageAdapter =
@ -200,9 +204,12 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
this?.adapter = imageAdapter this?.adapter = imageAdapter
} }
viewModel?.result?.observe(viewLifecycleOwner, Observer { viewModel?.result?.observe(
handleResult(it) viewLifecycleOwner,
}) Observer {
handleResult(it)
},
)
switch = binding?.switchWidget switch = binding?.switchWidget
switch?.visibility = View.VISIBLE switch?.visibility = View.VISIBLE
@ -323,20 +330,22 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
override fun onDestroy() { override fun onDestroy() {
imageAdapter.cleanUp() imageAdapter.cleanUp()
val position = (selectorRV?.layoutManager as GridLayoutManager) val position =
.findFirstVisibleItemPosition() (selectorRV?.layoutManager as GridLayoutManager)
.findFirstVisibleItemPosition()
// Check for empty RecyclerView. // Check for empty RecyclerView.
if (position != -1 && filteredImages.size > 0) { if (position != -1 && filteredImages.size > 0) {
context?.let { context -> context?.let { context ->
context.getSharedPreferences( context
"CustomSelector", .getSharedPreferences(
BaseActivity.MODE_PRIVATE "CustomSelector",
)?.let { prefs -> BaseActivity.MODE_PRIVATE,
prefs.edit()?.let { editor -> )?.let { prefs ->
editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply() prefs.edit()?.let { editor ->
editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply()
}
} }
}
} }
} }
super.onDestroy() super.onDestroy()
@ -354,7 +363,7 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
/** /**
* Removes the image from the actionable image map * Removes the image from the actionable image map
*/ */
fun removeImage(image : Image){ fun removeImage(image: Image) {
imageAdapter.removeImageFromActionableImageMap(image) imageAdapter.removeImageFromActionableImageMap(image)
} }
@ -364,11 +373,15 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
fun clearSelectedImages() { fun clearSelectedImages() {
imageAdapter.clearSelectedImages() imageAdapter.clearSelectedImages()
} }
/** /**
* Passes selected images and other information from Activity to Fragment and connects it with * Passes selected images and other information from Activity to Fragment and connects it with
* the adapter * the adapter
*/ */
override fun passSelectedImages(selectedImages: ArrayList<Image>, shouldRefresh: Boolean) { override fun passSelectedImages(
selectedImages: ArrayList<Image>,
shouldRefresh: Boolean,
) {
imageAdapter.setSelectedImages(selectedImages) imageAdapter.setSelectedImages(selectedImages)
val uploadingContributions = getUploadingContributions() val uploadingContributions = getUploadingContributions()
@ -398,11 +411,10 @@ class ImageFragment : CommonsDaggerSupportFragment(), RefreshUIListener, PassDat
} }
} }
private fun getUploadingContributions(): List<Contribution> { private fun getUploadingContributions(): List<Contribution> =
contributionDao
return contributionDao.getContribution( .getContribution(
listOf(Contribution.STATE_IN_PROGRESS, Contribution.STATE_FAILED, Contribution.STATE_QUEUED, Contribution.STATE_PAUSED) listOf(Contribution.STATE_IN_PROGRESS, Contribution.STATE_FAILED, Contribution.STATE_QUEUED, Contribution.STATE_PAUSED),
)?.subscribeOn(Schedulers.io())?.blockingGet() ?: emptyList() )?.subscribeOn(Schedulers.io())
} ?.blockingGet() ?: emptyList()
} }

View file

@ -15,368 +15,389 @@ import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1 import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineDispatcher
import java.util.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
/** /**
* Image Loader class, loads images, depending on API results. * Image Loader class, loads images, depending on API results.
*/ */
class ImageLoader @Inject constructor( class ImageLoader
@Inject
/** constructor(
* MediaClient for SHA1 query. /**
*/ * MediaClient for SHA1 query.
var mediaClient: MediaClient, */
var mediaClient: MediaClient,
/** /**
* FileProcessor to pre-process the file. * FileProcessor to pre-process the file.
*/ */
var fileProcessor: FileProcessor, var fileProcessor: FileProcessor,
/**
/** * File Utils Wrapper for SHA1
* File Utils Wrapper for SHA1 */
*/ var fileUtilsWrapper: FileUtilsWrapper,
var fileUtilsWrapper: FileUtilsWrapper, /**
* UploadedStatusDao for cache query.
/** */
* UploadedStatusDao for cache query. var uploadedStatusDao: UploadedStatusDao,
*/ /**
var uploadedStatusDao: UploadedStatusDao, * NotForUploadDao for database operations
*/
/** var notForUploadStatusDao: NotForUploadStatusDao,
* NotForUploadDao for database operations /**
*/ * Context for coroutine.
var notForUploadStatusDao: NotForUploadStatusDao, */
val context: Context,
/**
* 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>
) { ) {
/**
* 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 private val scope: CoroutineScope = MainScope()
holder.itemNotUploaded()
holder.itemForUpload()
holder.itemNotUploading()
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) { scope.launch {
return@launch var result: Result = Result.NOTFOUND
}
val imageSHA1: String = when (mapImageSHA1[image.uri] != null) { if (mapHolderImage[holder] != image) {
true -> mapImageSHA1[image.uri]!! return@launch
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) { val imageSHA1: String =
return@launch 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()) { val sha1 =
when { uploadedStatus?.let {
mapResult[imageSHA1] == null -> { result = getResultFromUploadedStatus(uploadedStatus)
// Query original image. uploadedStatus.modifiedImageSHA1
result = checkWhetherFileExistsOnCommonsUsingSHA1( } ?: run {
imageSHA1, if (mapHolderImage[holder] == image) {
ioDispatcher, getSHA1(image, defaultDispatcher)
mediaClient } else {
) ""
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
}
} }
} }
else -> {
result = mapResult[imageSHA1]!! if (mapHolderImage[holder] != image) {
} return@launch
} }
if (result is Result.TRUE) {
// Original image found. val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1)
insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false)
} else { if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
when { when {
mapResult[sha1] == null -> { mapResult[imageSHA1] == null -> {
// Original image not found, query modified image. // Query original image.
result = checkWhetherFileExistsOnCommonsUsingSHA1( result =
sha1, checkWhetherFileExistsOnCommonsUsingSHA1(
ioDispatcher, imageSHA1,
mediaClient ioDispatcher,
) mediaClient,
)
when (result) { when (result) {
is Result.TRUE -> { is Result.TRUE -> {
mapResult[sha1] = Result.TRUE mapResult[imageSHA1] = Result.TRUE
} }
is Result.ERROR -> { is Result.ERROR -> {
mapResult[sha1] = Result.ERROR mapResult[imageSHA1] = Result.ERROR
} }
is Result.FALSE -> { is Result.FALSE -> {
mapResult[sha1] = Result.FALSE mapResult[imageSHA1] = Result.FALSE
} }
is Result.INVALID -> { is Result.INVALID -> {
mapResult[sha1] = Result.INVALID mapResult[imageSHA1] = Result.INVALID
} }
is Result.NOTFOUND -> { is Result.NOTFOUND -> {
mapResult[sha1] = Result.NOTFOUND mapResult[imageSHA1] = Result.NOTFOUND
} }
} }
} }
else -> { else -> {
result = mapResult[sha1]!! result = mapResult[imageSHA1]!!
} }
} }
if (result != Result.ERROR) { if (result is Result.TRUE) {
insertIntoUploaded(imageSHA1, sha1, false, 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 = val sharedPreferences: SharedPreferences =
context context
.getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0) .getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val showAlreadyActionedImages = val showAlreadyActionedImages =
sharedPreferences.getBoolean( sharedPreferences.getBoolean(
ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY, ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY,
true true,
) )
if (mapHolderImage[holder] == image) { if (mapHolderImage[holder] == image) {
if ((result is Result.TRUE) && showAlreadyActionedImages) { if ((result is Result.TRUE) && showAlreadyActionedImages) {
holder.itemUploaded() 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
} else { } 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 * Finds out the next actionable image position
*/ */
suspend fun nextActionableImage( suspend fun nextActionableImage(
allImages: List<Image>, ioDispatcher: CoroutineDispatcher, allImages: List<Image>,
defaultDispatcher: CoroutineDispatcher, ioDispatcher: CoroutineDispatcher,
nextImagePosition: Int, defaultDispatcher: CoroutineDispatcher,
currentlyUploadingImages: List<Contribution> nextImagePosition: Int,
): Int { currentlyUploadingImages: List<Contribution>,
var next: Int ): Int {
// Traversing from given position to the end var next: Int
for (i in nextImagePosition until allImages.size){ // Traversing from given position to the end
val currentImage = allImages[i] for (i in nextImagePosition until allImages.size) {
val currentImage = allImages[i]
if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) { if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) {
continue // Skip this image as it's currently being uploaded continue // Skip this image as it's currently being uploaded
} }
val imageSHA1: String = when (mapImageSHA1[currentImage.uri] != null) { val imageSHA1: String =
true -> mapImageSHA1[currentImage.uri]!! when (mapImageSHA1[currentImage.uri] != null) {
else -> CustomSelectorUtils.getImageSHA1( true -> mapImageSHA1[currentImage.uri]!!
currentImage.uri, else ->
ioDispatcher, CustomSelectorUtils.getImageSHA1(
fileUtilsWrapper, currentImage.uri,
context.contentResolver ioDispatcher,
) fileUtilsWrapper,
} context.contentResolver,
next = notForUploadStatusDao.find(imageSHA1) )
}
next = notForUploadStatusDao.find(imageSHA1)
// After checking the image in the not for upload table, if the image is present then // 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 // skips the image and moves to next image for checking
if(next > 0){ if (next > 0) {
continue continue
// Otherwise checks in already uploaded table // Otherwise checks in already uploaded table
} else { } else {
next = uploadedStatusDao.findByImageSHA1(imageSHA1, true) next = uploadedStatusDao.findByImageSHA1(imageSHA1, true)
// If the image is not present in the already uploaded table, checks for its // If the image is not present in the already uploaded table, checks for its
// modified SHA1 in already uploaded table // 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 (next <= 0) { 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 // If the modified image SHA1 is not present in the already uploaded table,
// for checking // 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 { } else {
continue 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. * Get SHA1, return SHA1 if available, otherwise generate and store the SHA1.
* False Database Entries are invalid after INVALIDATE_DAY_COUNT and need to be re-queried. *
* @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
}
}

View file

@ -5,7 +5,10 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import fr.free.nrw.commons.contributions.Contribution import fr.free.nrw.commons.contributions.Contribution
import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.* import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.PlaceDao import fr.free.nrw.commons.nearby.PlaceDao
import fr.free.nrw.commons.review.ReviewDao import fr.free.nrw.commons.review.ReviewDao
@ -17,13 +20,22 @@ import fr.free.nrw.commons.upload.depicts.DepictsDao
* The database for accessing the respective DAOs * 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) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun contributionDao(): ContributionDao abstract fun contributionDao(): ContributionDao
abstract fun PlaceDao(): PlaceDao 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 NotForUploadStatusDao(): NotForUploadStatusDao
abstract fun ReviewDao(): ReviewDao abstract fun ReviewDao(): ReviewDao
} }

View file

@ -1,6 +1,5 @@
package fr.free.nrw.commons.description package fr.free.nrw.commons.description
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
@ -29,11 +28,12 @@ import io.reactivex.schedulers.Schedulers
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* Activity for populating and editing existing description and caption * 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 * Adapter for showing UploadMediaDetail in the activity
*/ */
@ -70,7 +70,7 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
private lateinit var binding: ActivityDescriptionEditBinding private lateinit var binding: ActivityDescriptionEditBinding
private val REQUEST_CODE_FOR_VOICE_INPUT = 1213 private val requestCodeForVoiceInput = 1213
private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null
@ -78,7 +78,6 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
@Inject lateinit var sessionManager: SessionManager @Inject lateinit var sessionManager: SessionManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -110,12 +109,17 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
* @param descriptionAndCaptions list of description and caption * @param descriptionAndCaptions list of description and caption
*/ */
private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) { private fun initRecyclerView(descriptionAndCaptions: ArrayList<UploadMediaDetail>?) {
uploadMediaDetailAdapter = UploadMediaDetailAdapter(this, uploadMediaDetailAdapter =
savedLanguageValue, descriptionAndCaptions, recentLanguagesDao) UploadMediaDetailAdapter(
this,
savedLanguageValue,
descriptionAndCaptions,
recentLanguagesDao,
)
uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int -> uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int ->
showInfoAlert( showInfoAlert(
titleStringID, titleStringID,
messageStringId messageStringId,
) )
} }
uploadMediaDetailAdapter.setEventListener(this) uploadMediaDetailAdapter.setEventListener(this)
@ -129,11 +133,17 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
* @param titleStringID Title ID * @param titleStringID Title ID
* @param messageStringId Message ID * @param messageStringId Message ID
*/ */
private fun showInfoAlert(titleStringID: Int, messageStringId: Int) { private fun showInfoAlert(
titleStringID: Int,
messageStringId: Int,
) {
showAlertDialog( showAlertDialog(
this, getString(titleStringID), this,
getString(messageStringId), getString(android.R.string.ok), getString(titleStringID),
null, true getString(messageStringId),
getString(android.R.string.ok),
null,
true,
) )
} }
@ -144,13 +154,13 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
*/ */
override fun addLanguage() { override fun addLanguage() {
val uploadMediaDetail = UploadMediaDetail() 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) uploadMediaDetailAdapter.addDescription(uploadMediaDetail)
rvDescriptions!!.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1) rvDescriptions!!.smoothScrollToPosition(uploadMediaDetailAdapter.itemCount - 1)
} }
private fun onBackButtonClicked(view: View) { private fun onBackButtonClicked(view: View) {
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
private fun onSubmitButtonClicked(view: View) { private fun onSubmitButtonClicked(view: View) {
@ -174,10 +184,11 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
val descriptionStart = wikiText!!.substring(0, descriptionIndex + 12) val descriptionStart = wikiText!!.substring(0, descriptionIndex + 12)
val descriptionToEnd = wikiText!!.substring(descriptionIndex + 12) val descriptionToEnd = wikiText!!.substring(descriptionIndex + 12)
val descriptionEndIndex = descriptionToEnd.indexOf("\n") val descriptionEndIndex = descriptionToEnd.indexOf("\n")
val descriptionEnd = wikiText!!.substring( val descriptionEnd =
descriptionStart.length wikiText!!.substring(
+ descriptionEndIndex descriptionStart.length +
) descriptionEndIndex,
)
buffer.append(descriptionStart) buffer.append(descriptionStart)
for (i in uploadMediaDetails.indices) { for (i in uploadMediaDetails.indices) {
val uploadDetails = uploadMediaDetails[i] val uploadDetails = uploadMediaDetails[i]
@ -203,65 +214,72 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
* @param updatedWikiText updated wiki text * @param updatedWikiText updated wiki text
* @param uploadMediaDetails descriptions and captions * @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 { try {
descriptionEditHelper?.addDescription( descriptionEditHelper
applicationContext, media, ?.addDescription(
updatedWikiText applicationContext,
) media,
?.subscribeOn(Schedulers.io()) updatedWikiText,
)?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread()) ?.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( compositeDisposable.add(
it it,
) )
} }
} catch (e : InvalidLoginTokenException) { } catch (e: InvalidLoginTokenException) {
val username: String? = sessionManager?.userName val username: String? = sessionManager?.userName
val logoutListener = CommonsApplication.BaseLogoutListener( val logoutListener =
this, CommonsApplication.BaseLogoutListener(
getString(R.string.invalid_login_message), this,
username getString(R.string.invalid_login_message),
) username,
)
val commonsApplication = CommonsApplication.getInstance() val commonsApplication = CommonsApplication.getInstance()
if (commonsApplication != null ){ if (commonsApplication != null) {
commonsApplication.clearApplicationData(this,logoutListener) commonsApplication.clearApplicationData(this, logoutListener)
} }
} }
val updatedCaptions = LinkedHashMap<String, String>() val updatedCaptions = LinkedHashMap<String, String>()
for (mediaDetail in uploadMediaDetails) { for (mediaDetail in uploadMediaDetails) {
try { try {
compositeDisposable.add( compositeDisposable.add(
descriptionEditHelper!!.addCaption( descriptionEditHelper!!
applicationContext, media, .addCaption(
mediaDetail.languageCode, mediaDetail.captionText applicationContext,
) media,
.subscribeOn(Schedulers.io()) mediaDetail.languageCode,
mediaDetail.captionText,
).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { s: Boolean? -> .subscribe { s: Boolean? ->
updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText updatedCaptions[mediaDetail.languageCode!!] = mediaDetail.captionText
media.captions = updatedCaptions media.captions = updatedCaptions
Timber.d("Caption is added.") 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() val commonsApplication = CommonsApplication.getInstance()
if (commonsApplication != null ){ if (commonsApplication != null) {
commonsApplication.clearApplicationData(this,logoutListener) commonsApplication.clearApplicationData(this, logoutListener)
} }
} }
} }
} }
@ -274,23 +292,29 @@ class DescriptionEditActivity : BaseActivity(), UploadMediaDetailAdapter.EventLi
progressDialog!!.show() progressDialog!!.show()
} }
override override fun onActivityResult(
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { requestCode: Int,
super.onActivityResult(requestCode, resultCode, data); resultCode: Int,
if (requestCode == REQUEST_CODE_FOR_VOICE_INPUT) { data: Intent?,
) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == requestCodeForVoiceInput) {
if (resultCode == RESULT_OK && data != null) { if (resultCode == RESULT_OK && data != null) {
val result = data.getStringArrayListExtra( RecognizerIntent.EXTRA_RESULTS ) val result = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
uploadMediaDetailAdapter.handleSpeechResult(result!![0]) } uploadMediaDetailAdapter.handleSpeechResult(result!![0])
else { Timber.e("Error %s", resultCode) } } else {
Timber.e("Error %s", resultCode)
}
} }
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, uploadMediaDetailAdapter.items as ArrayList<out Parcelable?>) outState.putParcelableArrayList(LIST_OF_DESCRIPTION_AND_CAPTION, uploadMediaDetailAdapter.items as ArrayList<out Parcelable?>)
outState.putString(WIKITEXT, wikiText) outState.putString(WIKITEXT, wikiText)
outState.putString(Prefs.DESCRIPTION_LANGUAGE, savedLanguageValue) outState.putString(Prefs.DESCRIPTION_LANGUAGE, savedLanguageValue)
//save Media // save Media
outState.putParcelable("media", media) outState.putParcelable("media", media)
} }
} }

View file

@ -6,5 +6,5 @@ package fr.free.nrw.commons.description
object EditDescriptionConstants { object EditDescriptionConstants {
const val LIST_OF_DESCRIPTION_AND_CAPTION = "description.descriptionAndCaption" const val LIST_OF_DESCRIPTION_AND_CAPTION = "description.descriptionAndCaption"
const val WIKITEXT = "description.wikiText" const val WIKITEXT = "description.wikiText"
const val UPDATED_WIKITEXT = "description.updatedWikiText"; const val UPDATED_WIKITEXT = "description.updatedWikiText"
} }

View file

@ -6,8 +6,7 @@ import dagger.Provides
import fr.free.nrw.commons.explore.map.ExploreMapFragment import fr.free.nrw.commons.explore.map.ExploreMapFragment
@Module @Module
class ExploreMapFragmentModule{ class ExploreMapFragmentModule {
@Provides @Provides
fun ExploreMapFragment.providesActivity(): Activity = activity!! fun ExploreMapFragment.providesActivity(): Activity = activity!!
} }

View file

@ -6,8 +6,7 @@ import dagger.Provides
import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment import fr.free.nrw.commons.nearby.fragments.NearbyParentFragment
@Module @Module
class NearbyParentFragmentModule{ class NearbyParentFragmentModule {
@Provides @Provides
fun NearbyParentFragment.providesActivity(): Activity = activity!! fun NearbyParentFragment.providesActivity(): Activity = activity!!
} }

View file

@ -44,31 +44,32 @@ class EditActivity : AppCompatActivity() {
imageUri = intent.getStringExtra("image") ?: "" imageUri = intent.getStringExtra("image") ?: ""
vm = ViewModelProvider(this).get(EditViewModel::class.java) vm = ViewModelProvider(this).get(EditViewModel::class.java)
val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) } val sourceExif = imageUri.toUri().path?.let { ExifInterface(it) }
val exifTags = arrayOf( val exifTags =
ExifInterface.TAG_APERTURE, arrayOf(
ExifInterface.TAG_DATETIME, ExifInterface.TAG_APERTURE,
ExifInterface.TAG_EXPOSURE_TIME, ExifInterface.TAG_DATETIME,
ExifInterface.TAG_FLASH, ExifInterface.TAG_EXPOSURE_TIME,
ExifInterface.TAG_FOCAL_LENGTH, ExifInterface.TAG_FLASH,
ExifInterface.TAG_GPS_ALTITUDE, ExifInterface.TAG_FOCAL_LENGTH,
ExifInterface.TAG_GPS_ALTITUDE_REF, ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_DATESTAMP, ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_LATITUDE, ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_LATITUDE_REF, ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LONGITUDE, ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE_REF, ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_PROCESSING_METHOD, ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_TIMESTAMP, ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_IMAGE_LENGTH, ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_IMAGE_WIDTH, ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_ISO, ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_MAKE, ExifInterface.TAG_ISO,
ExifInterface.TAG_MODEL, ExifInterface.TAG_MAKE,
ExifInterface.TAG_ORIENTATION, ExifInterface.TAG_MODEL,
ExifInterface.TAG_WHITE_BALANCE, ExifInterface.TAG_ORIENTATION,
ExifInterface.WHITEBALANCE_AUTO, ExifInterface.TAG_WHITE_BALANCE,
ExifInterface.WHITEBALANCE_MANUAL ExifInterface.WHITEBALANCE_AUTO,
) ExifInterface.WHITEBALANCE_MANUAL,
)
for (tag in exifTags) { for (tag in exifTags) {
val attribute = sourceExif?.getAttribute(tag.toString()) val attribute = sourceExif?.getAttribute(tag.toString())
sourceExifAttributeList.add(Pair(tag.toString(), attribute)) sourceExifAttributeList.add(Pair(tag.toString(), attribute))
@ -87,37 +88,38 @@ class EditActivity : AppCompatActivity() {
private fun init() { private fun init() {
binding.iv.adjustViewBounds = true binding.iv.adjustViewBounds = true
binding.iv.scaleType = ImageView.ScaleType.MATRIX binding.iv.scaleType = ImageView.ScaleType.MATRIX
binding.iv.post(Runnable { binding.iv.post(
val options = BitmapFactory.Options() Runnable {
options.inJustDecodeBounds = true val options = BitmapFactory.Options()
BitmapFactory.decodeFile(imageUri, options) options.inJustDecodeBounds = true
BitmapFactory.decodeFile(imageUri, options)
val bitmapWidth = options.outWidth val bitmapWidth = options.outWidth
val bitmapHeight = options.outHeight val bitmapHeight = options.outHeight
// Check if the bitmap dimensions exceed a certain threshold // Check if the bitmap dimensions exceed a certain threshold
val maxBitmapSize = 2000 // Set your maximum size here val maxBitmapSize = 2000 // Set your maximum size here
if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) { if (bitmapWidth > maxBitmapSize || bitmapHeight > maxBitmapSize) {
val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize) val scaleFactor = calculateScaleFactor(bitmapWidth, bitmapHeight, maxBitmapSize)
options.inSampleSize = scaleFactor options.inSampleSize = scaleFactor
options.inJustDecodeBounds = false options.inJustDecodeBounds = false
val scaledBitmap = BitmapFactory.decodeFile(imageUri, options) val scaledBitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(scaledBitmap) binding.iv.setImageBitmap(scaledBitmap)
// Update the ImageView with the scaled bitmap // Update the ImageView with the scaled bitmap
val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat() val scale = binding.iv.measuredWidth.toFloat() / scaledBitmap.width.toFloat()
binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt() binding.iv.layoutParams.height = (scale * scaledBitmap.height).toInt()
binding.iv.imageMatrix = scaleMatrix(scale, scale) binding.iv.imageMatrix = scaleMatrix(scale, scale)
} else { } else {
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(imageUri, options)
binding.iv.setImageBitmap(bitmap)
options.inJustDecodeBounds = false val scale = binding.iv.measuredWidth.toFloat() / bitmapWidth.toFloat()
val bitmap = BitmapFactory.decodeFile(imageUri, options) binding.iv.layoutParams.height = (scale * bitmapHeight).toInt()
binding.iv.setImageBitmap(bitmap) 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 { binding.rotateBtn.setOnClickListener {
animateImageHeight() animateImageHeight()
} }
@ -138,8 +140,16 @@ class EditActivity : AppCompatActivity() {
* further rotation actions. * further rotation actions.
*/ */
private fun animateImageHeight() { private fun animateImageHeight() {
val drawableWidth: Float = binding.iv.getDrawable().getIntrinsicWidth().toFloat() val drawableWidth: Float =
val drawableHeight: Float = binding.iv.getDrawable().getIntrinsicHeight().toFloat() binding.iv
.getDrawable()
.getIntrinsicWidth()
.toFloat()
val drawableHeight: Float =
binding.iv
.getDrawable()
.getIntrinsicHeight()
.toFloat()
val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat() val viewWidth: Float = binding.iv.getMeasuredWidth().toFloat()
val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat() val viewHeight: Float = binding.iv.getMeasuredHeight().toFloat()
val rotation = imageRotation % 360 val rotation = imageRotation % 360
@ -152,7 +162,6 @@ class EditActivity : AppCompatActivity() {
Timber.d("Rotation $rotation") Timber.d("Rotation $rotation")
Timber.d("new Rotation $newRotation") Timber.d("new Rotation $newRotation")
if (rotation == 0 || rotation == 180) { if (rotation == 0 || rotation == 180) {
imageScale = viewWidth / drawableWidth imageScale = viewWidth / drawableWidth
newImageScale = viewWidth / drawableHeight newImageScale = viewWidth / drawableHeight
@ -169,23 +178,24 @@ class EditActivity : AppCompatActivity() {
animator.interpolator = AccelerateDecelerateInterpolator() animator.interpolator = AccelerateDecelerateInterpolator()
animator.addListener(object : AnimatorListener { animator.addListener(
override fun onAnimationStart(animation: Animator) { object : AnimatorListener {
binding.rotateBtn.setEnabled(false) override fun onAnimationStart(animation: Animator) {
} binding.rotateBtn.setEnabled(false)
}
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
imageRotation = newRotation % 360 imageRotation = newRotation % 360
binding.rotateBtn.setEnabled(true) 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 -> animator.addUpdateListener { animation ->
val animVal = animation.animatedValue as Float val animVal = animation.animatedValue as Float
@ -195,20 +205,21 @@ class EditActivity : AppCompatActivity() {
val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale val animatedScale = complementaryAnimVal * imageScale + animVal * newImageScale
val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation val animatedRotation = complementaryAnimVal * rotation + animVal * newRotation
binding.iv.getLayoutParams().height = animatedHeight binding.iv.getLayoutParams().height = animatedHeight
val matrix: Matrix = rotationMatrix( val matrix: Matrix =
animatedRotation, rotationMatrix(
drawableWidth / 2, animatedRotation,
drawableHeight / 2 drawableWidth / 2,
) drawableHeight / 2,
)
matrix.postScale( matrix.postScale(
animatedScale, animatedScale,
animatedScale, animatedScale,
drawableWidth / 2, drawableWidth / 2,
drawableHeight / 2 drawableHeight / 2,
) )
matrix.postTranslate( matrix.postTranslate(
-(drawableWidth - binding.iv.getMeasuredWidth()) / 2, -(drawableWidth - binding.iv.getMeasuredWidth()) / 2,
-(drawableHeight - binding.iv.getMeasuredHeight()) / 2 -(drawableHeight - binding.iv.getMeasuredHeight()) / 2,
) )
binding.iv.setImageMatrix(matrix) binding.iv.setImageMatrix(matrix)
binding.iv.requestLayout() binding.iv.requestLayout()
@ -228,11 +239,9 @@ class EditActivity : AppCompatActivity() {
* as a result, and finishes the current activity. * as a result, and finishes the current activity.
*/ */
fun getRotatedImage() { fun getRotatedImage() {
val filePath = imageUri.toUri().path val filePath = imageUri.toUri().path
val file = filePath?.let { File(it) } val file = filePath?.let { File(it) }
val rotatedImage = file?.let { vm.rotateImage(imageRotation, it) } val rotatedImage = file?.let { vm.rotateImage(imageRotation, it) }
if (rotatedImage == null) { if (rotatedImage == null) {
Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show() Toast.makeText(this, "Failed to rotate to image", Toast.LENGTH_LONG).show()
@ -243,9 +252,9 @@ class EditActivity : AppCompatActivity() {
copyExifData(editedImageExif) copyExifData(editedImageExif)
} }
val resultIntent = Intent() val resultIntent = Intent()
resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error"); resultIntent.putExtra("editedImageFilePath", rotatedImage?.toUri()?.path ?: "Error")
setResult(RESULT_OK, resultIntent); setResult(RESULT_OK, resultIntent)
finish(); finish()
} }
/** /**
@ -257,7 +266,6 @@ class EditActivity : AppCompatActivity() {
* @param editedImageExif The ExifInterface object for the edited image. * @param editedImageExif The ExifInterface object for the edited image.
*/ */
private fun copyExifData(editedImageExif: ExifInterface?) { private fun copyExifData(editedImageExif: ExifInterface?) {
for (attr in sourceExifAttributeList) { for (attr in sourceExifAttributeList) {
Log.d("Tag is ${attr.first}", "Value is ${attr.second}") Log.d("Tag is ${attr.first}", "Value is ${attr.second}")
editedImageExif!!.setAttribute(attr.first, 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 * The scale factor ensures that the scaled bitmap will fit within the maximum size
* while maintaining aspect ratio. * 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 var scaleFactor = 1
if (originalWidth > maxSize || originalHeight > maxSize) { if (originalWidth > maxSize || originalHeight > maxSize) {
@ -295,7 +307,4 @@ class EditActivity : AppCompatActivity() {
return scaleFactor return scaleFactor
} }
}
}

View file

@ -9,8 +9,7 @@ import java.io.File
* This ViewModel class is responsible for managing image editing operations, such as * This ViewModel class is responsible for managing image editing operations, such as
* rotating images. It utilizes a TransformImage implementation to perform image transformations. * rotating images. It utilizes a TransformImage implementation to perform image transformations.
*/ */
class EditViewModel() : ViewModel() { class EditViewModel : ViewModel() {
// Ideally should be injected using DI // Ideally should be injected using DI
private val transformImage: TransformImage = TransformImageImpl() private val transformImage: TransformImage = TransformImageImpl()
@ -21,7 +20,8 @@ class EditViewModel() : ViewModel() {
* @param imageFile The File representing the image to be rotated. * @param imageFile The File representing the image to be rotated.
* @return The rotated image File, or null if the rotation operation fails. * @return The rotated image File, or null if the rotation operation fails.
*/ */
fun rotateImage(degree: Int, imageFile: File): File? { fun rotateImage(
return transformImage.rotateImage(imageFile, degree) degree: Int,
} imageFile: File,
} ): File? = transformImage.rotateImage(imageFile, degree)
}

View file

@ -9,7 +9,6 @@ import java.io.File
* implementations to provide specific functionality for tasks like rotating images. * implementations to provide specific functionality for tasks like rotating images.
*/ */
interface TransformImage { interface TransformImage {
/** /**
* Rotates the specified image file by the given degree. * 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. * @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails. * @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?
}

View 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 * 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. * 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. * 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. * @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails. * @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") Timber.tag("Trying to rotate image").d("Starting")
val path = Environment.getExternalStoragePublicDirectory( val path =
Environment.DIRECTORY_DOWNLOADS Environment.getExternalStoragePublicDirectory(
) Environment.DIRECTORY_DOWNLOADS,
)
val imagePath = System.currentTimeMillis() val imagePath = System.currentTimeMillis()
val file: File = File(path, "$imagePath.jpg") val file: File = File(path, "$imagePath.jpg")
val output = file val output = file
val rotated = try { val rotated =
val lljTran = LLJTran(imageFile) try {
lljTran.read( val lljTran = LLJTran(imageFile)
LLJTran.READ_ALL, lljTran.read(
false, LLJTran.READ_ALL,
) // This could throw an LLJTranException. I am not catching it for now... Let's see. false,
lljTran.transform( ) // This could throw an LLJTranException. I am not catching it for now... Let's see.
when(degree){ lljTran.transform(
90 -> LLJTran.ROT_90 when (degree) {
180 -> LLJTran.ROT_180 90 -> LLJTran.ROT_90
270 -> LLJTran.ROT_270 180 -> LLJTran.ROT_180
else -> { 270 -> LLJTran.ROT_270
LLJTran.ROT_90 else -> {
} LLJTran.ROT_90
}, }
LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION },
) LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION,
BufferedOutputStream(FileOutputStream(output)).use { writer -> )
lljTran.save(writer, LLJTran.OPT_WRITE_ALL ) 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) { if (rotated) {
Timber.tag("Done rotating image").d("Done") Timber.tag("Done rotating image").d("Done")

View file

@ -15,14 +15,11 @@ import fr.free.nrw.commons.explore.media.SearchMediaFragmentPresenterImpl
@Module @Module
abstract class SearchModule { abstract class SearchModule {
@Binds @Binds
abstract fun SearchDepictionsFragmentPresenterImpl.bindsSearchDepictionsFragmentPresenter() abstract fun SearchDepictionsFragmentPresenterImpl.bindsSearchDepictionsFragmentPresenter(): SearchDepictionsFragmentPresenter
: SearchDepictionsFragmentPresenter
@Binds @Binds
abstract fun SearchCategoriesFragmentPresenterImpl.bindsSearchCategoriesFragmentPresenter() abstract fun SearchCategoriesFragmentPresenterImpl.bindsSearchCategoriesFragmentPresenter(): SearchCategoriesFragmentPresenter
: SearchCategoriesFragmentPresenter
@Binds @Binds
abstract fun SearchMediaFragmentPresenterImpl.bindsSearchMediaFragmentPresenter() abstract fun SearchMediaFragmentPresenterImpl.bindsSearchMediaFragmentPresenter(): SearchMediaFragmentPresenter
: SearchMediaFragmentPresenter
} }

View file

@ -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.SubCategoriesPresenter
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesPresenterImpl import fr.free.nrw.commons.explore.categories.sub.SubCategoriesPresenterImpl
@Module @Module
abstract class CategoriesModule { abstract class CategoriesModule {
@Binds
abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter(): CategoryMediaPresenter
@Binds @Binds
abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter() abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter(): SubCategoriesPresenter
: CategoryMediaPresenter
@Binds @Binds
abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter() abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter(): ParentCategoriesPresenter
: SubCategoriesPresenter
@Binds
abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter()
: ParentCategoriesPresenter
} }

View file

@ -4,7 +4,6 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.category.CategoryDetailsActivity import fr.free.nrw.commons.category.CategoryDetailsActivity
import fr.free.nrw.commons.explore.paging.BasePagingFragment import fr.free.nrw.commons.explore.paging.BasePagingFragment
abstract class PageableCategoryFragment : BasePagingFragment<String>() { abstract class PageableCategoryFragment : BasePagingFragment<String>() {
override val errorTextId: Int = R.string.error_loading_categories override val errorTextId: Int = R.string.error_loading_categories
override val pagedListAdapter by lazy { override val pagedListAdapter by lazy {

View file

@ -8,31 +8,44 @@ import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.category.CATEGORY_PREFIX import fr.free.nrw.commons.category.CATEGORY_PREFIX
import fr.free.nrw.commons.databinding.ItemRecentSearchesBinding import fr.free.nrw.commons.databinding.ItemRecentSearchesBinding
class PagedSearchCategoriesAdapter(private val onCategoryClicked: (String) -> Unit) : class PagedSearchCategoriesAdapter(
PagedListAdapter<String, CategoryItemViewHolder>(PagedSearchCategoriesDiffUtilCallback) { private val onCategoryClicked: (String) -> Unit,
) : PagedListAdapter<String, CategoryItemViewHolder>(PagedSearchCategoriesDiffUtilCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = CategoryItemViewHolder( override fun onCreateViewHolder(
ItemRecentSearchesBinding.inflate(LayoutInflater.from(parent.context), parent, false) 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) holder.bind(getItem(position)!!, onCategoryClicked)
} }
} }
class CategoryItemViewHolder( class CategoryItemViewHolder(
private val binding: ItemRecentSearchesBinding private val binding: ItemRecentSearchesBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : 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) } root.setOnClickListener { onCategoryClicked(item) }
textView1.text = item.substringAfter(CATEGORY_PREFIX) textView1.text = item.substringAfter(CATEGORY_PREFIX)
} }
} }
private object PagedSearchCategoriesDiffUtilCallback : DiffUtil.ItemCallback<String>() { private object PagedSearchCategoriesDiffUtilCallback : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String) = override fun areItemsTheSame(
oldItem == newItem oldItem: String,
newItem: String,
) = oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String) = override fun areContentsTheSame(
oldItem == newItem oldItem: String,
newItem: String,
) = oldItem == newItem
} }

View file

@ -6,16 +6,17 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX
import fr.free.nrw.commons.explore.media.PageableMediaFragment import fr.free.nrw.commons.explore.media.PageableMediaFragment
import javax.inject.Inject import javax.inject.Inject
class CategoriesMediaFragment : PageableMediaFragment() { class CategoriesMediaFragment : PageableMediaFragment() {
@Inject @Inject
lateinit var presenter: CategoryMediaPresenter lateinit var presenter: CategoryMediaPresenter
override val injectedPresenter override val injectedPresenter
get() = presenter get() = presenter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}")
} }

View file

@ -13,8 +13,10 @@ interface CategoryMediaPresenter : PagingContract.Presenter<Media>
/** /**
* Presenter for DepictedImagesFragment * Presenter for DepictedImagesFragment
*/ */
class CategoryMediaPresenterImpl @Inject constructor( class CategoryMediaPresenterImpl
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, @Inject
dataSourceFactory: PageableCategoriesMediaDataSource constructor(
) : BasePagingPresenter<Media>(mainThreadScheduler, dataSourceFactory), @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
CategoryMediaPresenter dataSourceFactory: PageableCategoriesMediaDataSource,
) : BasePagingPresenter<Media>(mainThreadScheduler, dataSourceFactory),
CategoryMediaPresenter

View file

@ -7,14 +7,16 @@ import fr.free.nrw.commons.explore.paging.PageableBaseDataSource
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import javax.inject.Inject import javax.inject.Inject
class PageableCategoriesMediaDataSource @Inject constructor( class PageableCategoriesMediaDataSource
liveDataConverter: LiveDataConverter, @Inject
private val mediaClient: MediaClient constructor(
) : PageableBaseDataSource<Media>(liveDataConverter) { liveDataConverter: LiveDataConverter,
override val loadFunction: LoadFunction<Media> = { loadSize: Int, startPosition: Int -> private val mediaClient: MediaClient,
if(startPosition == 0){ ) : PageableBaseDataSource<Media>(liveDataConverter) {
mediaClient.resetCategoryContinuation(query) override val loadFunction: LoadFunction<Media> = { loadSize: Int, startPosition: Int ->
if (startPosition == 0) {
mediaClient.resetCategoryContinuation(query)
}
mediaClient.getMediaListFromCategory(query).blockingGet()
} }
mediaClient.getMediaListFromCategory(query).blockingGet()
} }
}

View file

@ -5,15 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter
import fr.free.nrw.commons.explore.paging.PageableBaseDataSource import fr.free.nrw.commons.explore.paging.PageableBaseDataSource
import javax.inject.Inject import javax.inject.Inject
class PageableParentCategoriesDataSource @Inject constructor( class PageableParentCategoriesDataSource
liveDataConverter: LiveDataConverter, @Inject
val categoryClient: CategoryClient constructor(
) : PageableBaseDataSource<String>(liveDataConverter) { liveDataConverter: LiveDataConverter,
val categoryClient: CategoryClient,
override val loadFunction = { loadSize: Int, startPosition: Int -> ) : PageableBaseDataSource<String>(liveDataConverter) {
if (startPosition == 0) { override val loadFunction = { loadSize: Int, startPosition: Int ->
categoryClient.resetParentCategoryContinuation(query) if (startPosition == 0) {
categoryClient.resetParentCategoryContinuation(query)
}
categoryClient.getParentCategoryList(query).blockingGet().map { it.name }
} }
categoryClient.getParentCategoryList(query).blockingGet().map { it.name }
} }
}

View file

@ -7,9 +7,7 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX
import fr.free.nrw.commons.explore.categories.PageableCategoryFragment import fr.free.nrw.commons.explore.categories.PageableCategoryFragment
import javax.inject.Inject import javax.inject.Inject
class ParentCategoriesFragment : PageableCategoryFragment() { class ParentCategoriesFragment : PageableCategoryFragment() {
@Inject @Inject
lateinit var presenter: ParentCategoriesPresenter lateinit var presenter: ParentCategoriesPresenter
@ -18,9 +16,11 @@ class ParentCategoriesFragment : PageableCategoryFragment() {
override fun getEmptyText(query: String) = getString(R.string.no_parentcategory_found) 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) super.onViewCreated(view, savedInstanceState)
onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}")
} }
} }

View file

@ -7,11 +7,12 @@ import io.reactivex.Scheduler
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
interface ParentCategoriesPresenter : PagingContract.Presenter<String> interface ParentCategoriesPresenter : PagingContract.Presenter<String>
class ParentCategoriesPresenterImpl @Inject constructor( class ParentCategoriesPresenterImpl
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, @Inject
dataSourceFactory: PageableParentCategoriesDataSource constructor(
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
ParentCategoriesPresenter dataSourceFactory: PageableParentCategoriesDataSource,
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory),
ParentCategoriesPresenter

View file

@ -5,13 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter
import fr.free.nrw.commons.explore.paging.PageableBaseDataSource import fr.free.nrw.commons.explore.paging.PageableBaseDataSource
import javax.inject.Inject import javax.inject.Inject
class PageableSearchCategoriesDataSource @Inject constructor( class PageableSearchCategoriesDataSource
liveDataConverter: LiveDataConverter, @Inject
val categoryClient: CategoryClient constructor(
) : PageableBaseDataSource<String>(liveDataConverter) { liveDataConverter: LiveDataConverter,
val categoryClient: CategoryClient,
override val loadFunction = { loadSize: Int, startPosition: Int -> ) : PageableBaseDataSource<String>(liveDataConverter) {
categoryClient.searchCategories(query, loadSize, startPosition).blockingGet() override val loadFunction = { loadSize: Int, startPosition: Int ->
.map { it.name } categoryClient
.searchCategories(query, loadSize, startPosition)
.blockingGet()
.map { it.name }
}
} }
}

View file

@ -9,8 +9,10 @@ import javax.inject.Named
interface SearchCategoriesFragmentPresenter : PagingContract.Presenter<String> interface SearchCategoriesFragmentPresenter : PagingContract.Presenter<String>
class SearchCategoriesFragmentPresenterImpl @Inject constructor( class SearchCategoriesFragmentPresenterImpl
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, @Inject
dataSourceFactory: PageableSearchCategoriesDataSource constructor(
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
SearchCategoriesFragmentPresenter dataSourceFactory: PageableSearchCategoriesDataSource,
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory),
SearchCategoriesFragmentPresenter

View file

@ -5,15 +5,16 @@ import fr.free.nrw.commons.explore.paging.LiveDataConverter
import fr.free.nrw.commons.explore.paging.PageableBaseDataSource import fr.free.nrw.commons.explore.paging.PageableBaseDataSource
import javax.inject.Inject import javax.inject.Inject
class PageableSubCategoriesDataSource @Inject constructor( class PageableSubCategoriesDataSource
liveDataConverter: LiveDataConverter, @Inject
val categoryClient: CategoryClient constructor(
) : PageableBaseDataSource<String>(liveDataConverter) { liveDataConverter: LiveDataConverter,
val categoryClient: CategoryClient,
override val loadFunction = { loadSize: Int, startPosition: Int -> ) : PageableBaseDataSource<String>(liveDataConverter) {
if (startPosition == 0) { override val loadFunction = { loadSize: Int, startPosition: Int ->
categoryClient.resetSubCategoryContinuation(query) if (startPosition == 0) {
categoryClient.resetSubCategoryContinuation(query)
}
categoryClient.getSubCategoryList(query).blockingGet().map { it.name }
} }
categoryClient.getSubCategoryList(query).blockingGet().map { it.name }
} }
}

View file

@ -7,9 +7,7 @@ import fr.free.nrw.commons.category.CATEGORY_PREFIX
import fr.free.nrw.commons.explore.categories.PageableCategoryFragment import fr.free.nrw.commons.explore.categories.PageableCategoryFragment
import javax.inject.Inject import javax.inject.Inject
class SubCategoriesFragment : PageableCategoryFragment() { class SubCategoriesFragment : PageableCategoryFragment() {
@Inject lateinit var presenter: SubCategoriesPresenter @Inject lateinit var presenter: SubCategoriesPresenter
override val injectedPresenter override val injectedPresenter
@ -17,7 +15,10 @@ class SubCategoriesFragment : PageableCategoryFragment() {
override fun getEmptyText(query: String) = getString(R.string.no_subcategory_found) 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) super.onViewCreated(view, savedInstanceState)
onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}") onQueryUpdated("$CATEGORY_PREFIX${arguments!!.getString("categoryName")!!}")
} }

View file

@ -9,8 +9,10 @@ import javax.inject.Named
interface SubCategoriesPresenter : PagingContract.Presenter<String> interface SubCategoriesPresenter : PagingContract.Presenter<String>
class SubCategoriesPresenterImpl @Inject constructor( class SubCategoriesPresenterImpl
@Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler, @Inject
dataSourceFactory: PageableSubCategoriesDataSource constructor(
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory), @Named(CommonsApplicationModule.MAIN_THREAD) mainThreadScheduler: Scheduler,
SubCategoriesPresenter dataSourceFactory: PageableSubCategoriesDataSource,
) : BasePagingPresenter<String>(mainThreadScheduler, dataSourceFactory),
SubCategoriesPresenter

View file

@ -9,22 +9,31 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.databinding.ItemDepictionsBinding import fr.free.nrw.commons.databinding.ItemDepictionsBinding
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
class DepictionAdapter(private val onDepictionClicked: (DepictedItem) -> Unit) : class DepictionAdapter(
PagedListAdapter<DepictedItem, DepictedItemViewHolder>(DepictionDiffUtilCallback) { private val onDepictionClicked: (DepictedItem) -> Unit,
) : PagedListAdapter<DepictedItem, DepictedItemViewHolder>(DepictionDiffUtilCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = DepictedItemViewHolder( override fun onCreateViewHolder(
ItemDepictionsBinding.inflate(LayoutInflater.from(parent.context), parent, false) 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) holder.bind(getItem(position)!!, onDepictionClicked)
} }
} }
class DepictedItemViewHolder( class DepictedItemViewHolder(
private val binding: ItemDepictionsBinding private val binding: ItemDepictionsBinding,
) : RecyclerView.ViewHolder(binding.root) { ) : 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) } root.setOnClickListener { onDepictionClicked(item) }
depictsLabel.text = item.name depictsLabel.text = item.name
description.text = item.description description.text = item.description
@ -37,9 +46,13 @@ class DepictedItemViewHolder(
} }
private object DepictionDiffUtilCallback : DiffUtil.ItemCallback<DepictedItem>() { private object DepictionDiffUtilCallback : DiffUtil.ItemCallback<DepictedItem>() {
override fun areItemsTheSame(oldItem: DepictedItem, newItem: DepictedItem) = override fun areItemsTheSame(
oldItem.id == newItem.id oldItem: DepictedItem,
newItem: DepictedItem,
) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: DepictedItem, newItem: DepictedItem) = override fun areContentsTheSame(
oldItem == newItem oldItem: DepictedItem,
newItem: DepictedItem,
) = oldItem == newItem
} }

View file

@ -14,16 +14,12 @@ import fr.free.nrw.commons.explore.depictions.parent.ParentDepictionsPresenterIm
*/ */
@Module @Module
abstract class DepictionModule { abstract class DepictionModule {
@Binds
abstract fun ParentDepictionsPresenterImpl.bindsParentDepictionPresenter(): ParentDepictionsPresenter
@Binds @Binds
abstract fun ParentDepictionsPresenterImpl.bindsParentDepictionPresenter() abstract fun ChildDepictionsPresenterImpl.bindsChildDepictionPresenter(): ChildDepictionsPresenter
: ParentDepictionsPresenter
@Binds @Binds
abstract fun ChildDepictionsPresenterImpl.bindsChildDepictionPresenter() abstract fun DepictedImagesPresenterImpl.bindsDepictedImagesContractPresenter(): DepictedImagesPresenter
: ChildDepictionsPresenter
@Binds
abstract fun DepictedImagesPresenterImpl.bindsDepictedImagesContractPresenter()
: DepictedImagesPresenter
} }

View file

@ -10,9 +10,9 @@ import fr.free.nrw.commons.wikidata.WikidataProperties
import fr.free.nrw.commons.wikidata.model.DataValue import fr.free.nrw.commons.wikidata.model.DataValue
import fr.free.nrw.commons.wikidata.model.DepictSearchItem import fr.free.nrw.commons.wikidata.model.DepictSearchItem
import fr.free.nrw.commons.wikidata.model.Entities import fr.free.nrw.commons.wikidata.model.Entities
import fr.free.nrw.commons.wikidata.model.Statement_partial import fr.free.nrw.commons.wikidata.model.StatementPartial
import io.reactivex.Single import io.reactivex.Single
import java.util.* import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -20,89 +20,101 @@ import javax.inject.Singleton
* Depicts Client to handle custom calls to Commons Wikibase APIs * Depicts Client to handle custom calls to Commons Wikibase APIs
*/ */
@Singleton @Singleton
class DepictsClient @Inject constructor(private val depictsInterface: DepictsInterface) { class DepictsClient
@Inject
/** constructor(
* Search for depictions using the search item private val depictsInterface: DepictsInterface,
* @return list of depicted items ) {
*/ /**
fun searchForDepictions(query: String?, limit: Int, offset: Int): Single<List<DepictedItem>> { * Search for depictions using the search item
val language = Locale.getDefault().language * @return list of depicted items
return depictsInterface.searchForDepicts(query, "$limit", language, language, "$offset") */
.map { it.search.joinToString("|", transform = DepictSearchItem::id) } fun searchForDepictions(
.mapToDepictions() query: String?,
} limit: Int,
offset: Int,
fun getEntities(ids: String): Single<Entities> { ): Single<List<DepictedItem>> {
return depictsInterface.getEntities(ids) val language = Locale.getDefault().language
} return depictsInterface
.searchForDepicts(query, "$limit", language, language, "$offset")
fun toDepictions(sparqlResponse: Single<SparqlResponse>): Single<List<DepictedItem>> { .map { it.search.joinToString("|", transform = DepictSearchItem::id) }
return sparqlResponse.map { .mapToDepictions()
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)
}
} }
/** fun getEntities(ids: String): Single<Entities> = depictsInterface.getEntities(ids)
* Convert different entities into DepictedItem
*/ fun toDepictions(sparqlResponse: Single<SparqlResponse>): Single<List<DepictedItem>> =
private fun mapToDepictItem(entity: Entities.Entity): DepictedItem { sparqlResponse
return if (entity.descriptions().byLanguageOrFirstOrEmpty() == "") { .map {
val instanceOfIDs = entity[WikidataProperties.INSTANCE_OF] it.results.bindings.joinToString("|", transform = Binding::id)
.toIds() }.mapToDepictions()
if (instanceOfIDs.isNotEmpty()) {
val entities: Entities = getEntities(instanceOfIDs[0]).blockingGet() /**
val nameAsDescription = entities.entities().values.first().labels() * Fetches Entities from ids ex. "Q1233|Q546" and converts them into DepictedItem
.byLanguageOrFirstOrEmpty() */
DepictedItem( @SuppressLint("CheckResult")
entity, private fun Single<String>.mapToDepictions() =
entity.labels().byLanguageOrFirstOrEmpty(), flatMap(::getEntities)
nameAsDescription .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 { } else {
DepictedItem( DepictedItem(
entity, entity,
entity.labels().byLanguageOrFirstOrEmpty(), 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. * 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 returns null, Tries to retrieve first element from the map.
* If that still returns null, function returns "". * If that still returns null, function returns "".
*/ */
private fun Map<String, Entities.Label>.byLanguageOrFirstOrEmpty() = private fun Map<String, Entities.Label>.byLanguageOrFirstOrEmpty() =
let { let {
it[Locale.getDefault().language] ?: it.values.firstOrNull() }?.value() ?: "" it[Locale.getDefault().language] ?: it.values.firstOrNull()
}?.value() ?: ""
/** /**
* returns list of id ex. "Q2323" from Statement_partial * returns list of id ex. "Q2323" from Statement_partial
*/ */
private fun List<Statement_partial>?.toIds(): List<String> { private fun List<StatementPartial>?.toIds(): List<String> =
return this?.map { it.mainSnak.dataValue } this
?.filterIsInstance<DataValue.EntityId>() ?.map { it.mainSnak.dataValue }
?.map { it.value.id } ?.filterIsInstance<DataValue.EntityId>()
?: emptyList() ?.map { it.value.id }
?: emptyList()
} }
}

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