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)
class AboutActivityTest {
@get:Rule
var activityRule: ActivityTestRule<*> = ActivityTestRule(AboutActivity::class.java)
@ -36,7 +35,8 @@ class AboutActivityTest {
device.setOrientationNatural()
device.freezeRotation()
Intents.init()
Intents.intending(CoreMatchers.not(IntentMatchers.isInternal()))
Intents
.intending(CoreMatchers.not(IntentMatchers.isInternal()))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
}
@ -47,11 +47,12 @@ class AboutActivityTest {
@Test
fun testBuildNumber() {
Espresso.onView(ViewMatchers.withId(R.id.about_version))
Espresso
.onView(ViewMatchers.withId(R.id.about_version))
.check(
ViewAssertions.matches(
withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha())
)
withText(getApplicationContext<CommonsApplication>().getVersionNameWithSha()),
),
)
}
@ -61,8 +62,8 @@ class AboutActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.WEBSITE_URL)
)
IntentMatchers.hasData(Urls.WEBSITE_URL),
),
)
}
@ -73,8 +74,8 @@ class AboutActivityTest {
CoreMatchers.anyOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FACEBOOK_WEB_URL),
IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME)
)
IntentMatchers.hasPackage(Urls.FACEBOOK_PACKAGE_NAME),
),
)
}
@ -84,8 +85,8 @@ class AboutActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.GITHUB_REPO_URL)
)
IntentMatchers.hasData(Urls.GITHUB_REPO_URL),
),
)
}
@ -95,8 +96,8 @@ class AboutActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL)
)
IntentMatchers.hasData(BuildConfig.PRIVACY_POLICY_URL),
),
)
}
@ -108,8 +109,8 @@ class AboutActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode")
)
IntentMatchers.hasData("${Urls.TRANSLATE_WIKI_URL}$langCode"),
),
)
}
@ -119,27 +120,30 @@ class AboutActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.CREDITS_URL)
)
IntentMatchers.hasData(Urls.CREDITS_URL),
),
)
}
@Test
fun testLaunchUserGuide() {
Espresso.onView(ViewMatchers.withId(R.id.about_user_guide)).perform(ViewActions.click())
Intents.intended(CoreMatchers.allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.USER_GUIDE_URL)))
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.USER_GUIDE_URL),
),
)
}
@Test
fun testLaunchAboutFaq() {
Espresso.onView(ViewMatchers.withId(R.id.about_faq)).perform(ViewActions.click())
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(Urls.FAQ_URL)
)
IntentMatchers.hasData(Urls.FAQ_URL),
),
)
}
}

View file

@ -18,12 +18,14 @@ import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SignupActivity
import org.hamcrest.CoreMatchers
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
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
var activityRule = ActivityTestRule(LoginActivity::class.java)
@ -49,8 +51,8 @@ class LoginActivityTest {
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW),
IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL)
)
IntentMatchers.hasData(BuildConfig.FORGOT_PASSWORD_URL),
),
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
package fr.free.nrw.commons
import androidx.core.text.HtmlCompat
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import fr.free.nrw.commons.media.IdAndCaptions
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.media.PAGE_ID_PREFIX
import io.reactivex.Single
import timber.log.Timber
import javax.inject.Inject
@ -17,42 +17,46 @@ import javax.inject.Singleton
* to the media and may change due to editing.
*/
@Singleton
class MediaDataExtractor @Inject constructor(private val mediaClient: MediaClient) {
class MediaDataExtractor
@Inject
constructor(
private val mediaClient: MediaClient,
) {
fun fetchDepictionIdsAndLabels(media: Media) =
mediaClient
.getEntities(media.depictionIds)
.map {
it
.entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}.map { it.map { (key, value) -> IdAndCaptions(key, value) } }
.onErrorReturn { emptyList() }
fun fetchDepictionIdsAndLabels(media: Media) =
mediaClient.getEntities(media.depictionIds)
.map {
it.entities()
.mapValues { entry -> entry.value.labels().mapValues { it.value.value() } }
}
.map { it.map { (key, value) -> IdAndCaptions(key, value) } }
.onErrorReturn { emptyList() }
fun checkDeletionRequestExists(media: Media) = mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
fun checkDeletionRequestExists(media: Media) =
mediaClient.checkPageExistsUsingTitle("Commons:Deletion_requests/" + media.filename)
fun fetchDiscussion(media: Media) =
mediaClient
.getPageHtml(media.filename!!.replace("File", "File talk"))
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
.onErrorReturn {
Timber.d("Error occurred while fetching discussion")
""
}
fun fetchDiscussion(media: Media) =
mediaClient.getPageHtml(media.filename!!.replace("File", "File talk"))
.map { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() }
.onErrorReturn {
Timber.d("Error occurred while fetching discussion")
""
}
fun refresh(media: Media): Single<Media> =
Single.ambArray(
mediaClient
.getMediaById(PAGE_ID_PREFIX + media.pageId)
.onErrorResumeNext { Single.never() },
mediaClient
.getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() },
)
fun refresh(media: Media): Single<Media> {
return Single.ambArray(
mediaClient.getMediaById(PAGE_ID_PREFIX + media.pageId)
.onErrorResumeNext { Single.never() },
mediaClient.getMediaSuppressingErrors(media.filename)
.onErrorResumeNext { Single.never() }
)
fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title)
/**
* Fetches wikitext from mediaClient
*/
fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title)
}
fun getHtmlOfPage(title: String) = mediaClient.getPageHtml(title);
/**
* Fetches wikitext from mediaClient
*/
fun getCurrentWikiText(title: String) = mediaClient.getCurrentWikiText(title);
}

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 PLAY_STORE_PREFIX = "market://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_APP_URL = "fb://page/1921335171459985"
const val FACEBOOK_PACKAGE_NAME = "com.facebook.katana"

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -13,9 +13,7 @@ class LoginResponse {
@SerializedName("clientlogin")
private val clientLogin: ClientLogin? = null
fun toLoginResult(password: String): LoginResult? {
return clientLogin?.toLoginResult(password)
}
fun toLoginResult(password: String): LoginResult? = clientLogin?.toLoginResult(password)
}
internal class ClientLogin {
@ -39,7 +37,7 @@ internal class ClientLogin {
}
}
} else if ("PASS" != status && "FAIL" != status) {
//TODO: String resource -- Looks like needed for others in this class too
// TODO: String resource -- Looks like needed for others in this class too
userMessage = "An unknown error occurred."
}
return Result(status ?: "", userName, password, userMessage)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,62 +12,61 @@ import javax.inject.Named
/**
* Data-Source which acts as mediator for contributions-data from the API
*/
class ContributionsRemoteDataSource @Inject constructor(
private val mediaClient: MediaClient,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler
) : ItemKeyedDataSource<Int, Contribution>() {
private val compositeDisposable: CompositeDisposable = CompositeDisposable()
var userName: String? = null
class ContributionsRemoteDataSource
@Inject
constructor(
private val mediaClient: MediaClient,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler,
) : ItemKeyedDataSource<Int, Contribution>() {
private val compositeDisposable: CompositeDisposable = CompositeDisposable()
var userName: String? = null
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Contribution>
) {
fetchContributions(callback)
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Contribution>,
) {
fetchContributions(callback)
}
override fun loadAfter(
params: LoadParams<Int>,
callback: LoadCallback<Contribution>,
) {
fetchContributions(callback)
}
override fun loadBefore(
params: LoadParams<Int>,
callback: LoadCallback<Contribution>,
) {
}
override fun getKey(item: Contribution): Int = item.pageId.hashCode()
/**
* Fetches contributions using the MediaWiki API
*/
private fun fetchContributions(callback: LoadCallback<Contribution>) {
compositeDisposable.add(
mediaClient
.getMediaListForUser(userName!!)
.map { mediaList ->
mediaList.map {
Contribution(media = it, state = Contribution.STATE_COMPLETED)
}
}.subscribeOn(ioThreadScheduler)
.subscribe({
callback.onResult(it)
}) { error: Throwable ->
Timber.e(
"Failed to fetch contributions: %s",
error.message,
)
},
)
}
fun dispose() {
compositeDisposable.dispose()
}
}
override fun loadAfter(
params: LoadParams<Int>,
callback: LoadCallback<Contribution>
) {
fetchContributions(callback)
}
override fun loadBefore(
params: LoadParams<Int>,
callback: LoadCallback<Contribution>
) {
}
override fun getKey(item: Contribution): Int {
return item.pageId.hashCode()
}
/**
* Fetches contributions using the MediaWiki API
*/
private fun fetchContributions(callback: LoadCallback<Contribution>) {
compositeDisposable.add(
mediaClient.getMediaListForUser(userName!!)
.map { mediaList ->
mediaList.map {
Contribution(media = it, state = Contribution.STATE_COMPLETED)
}
}
.subscribeOn(ioThreadScheduler)
.subscribe({
callback.onResult(it)
}) { error: Throwable ->
Timber.e(
"Failed to fetch contributions: %s",
error.message
)
}
)
}
fun dispose() {
compositeDisposable.dispose()
}
}

View file

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

View file

@ -1,16 +1,16 @@
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(tableName = "images_not_for_upload_table")
data class NotForUploadStatus(
/**
* Original image sha1.
*/
@PrimaryKey
val imageSHA1 : String
val imageSHA1: String,
)

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -4,12 +4,15 @@ package fr.free.nrw.commons.customselector.listeners
* Custom Selector Folder Click Listener
*/
interface FolderClickListener {
/**
* onFolderClick
* @param folderId : folder id of the folder.
* @param folderName : folder name of the folder.
* @param lastItemId : last scroll position in the folder.
*/
fun onFolderClick(folderId: Long, folderName: String, lastItemId: Long)
fun onFolderClick(
folderId: Long,
folderName: String,
lastItemId: Long,
)
}

View file

@ -7,7 +7,6 @@ import fr.free.nrw.commons.customselector.model.Image
* responds to the device image query.
*/
interface ImageLoaderListener {
/**
* On image loaded
* @param images : queried device images.

View file

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

View file

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

View file

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

View file

@ -5,27 +5,22 @@ package fr.free.nrw.commons.customselector.model
*/
data class Folder(
/**
bucketId : Unique directory id, eg 540528482
bucketId : Unique directory id, eg 540528482
*/
var bucketId: Long,
/**
name : bucket/folder name, eg Camera
name : bucket/folder name, eg Camera
*/
var name: String,
/**
images : folder images, list of all images under this folder.
images : folder images, list of all images under this folder.
*/
var images: ArrayList<Image> = arrayListOf<Image>()
var images: ArrayList<Image> = arrayListOf<Image>(),
) {
/**
* Indicates whether some other object is "equal to" this one.
*/
override fun equals(other: Any?): Boolean {
if (javaClass != other?.javaClass) {
return false
}

View file

@ -9,65 +9,60 @@ import android.os.Parcelable
*/
data class Image(
/**
id : Unique image id, primary key of image in device, eg 104950
id : Unique image id, primary key of image in device, eg 104950
*/
var id: Long,
/**
name : Name of the image with extension, eg CommonsLogo.jpeg
name : Name of the image with extension, eg CommonsLogo.jpeg
*/
var name: String,
/**
uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10)
uri : Uri of the image, points to image location or name, eg content://media/external/images/camera/10495 (Android 10)
*/
var uri: Uri,
/**
path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg
path : System path of the image, eg storage/emulated/0/camera/CommonsLogo.jpeg
*/
var path: String,
/**
bucketId : bucketId of folder, eg 540528482
bucketId : bucketId of folder, eg 540528482
*/
var bucketId: Long = 0,
/**
bucketName : name of folder, eg Camera
bucketName : name of folder, eg Camera
*/
var bucketName: String = "",
/**
sha1 : sha1 of original image.
sha1 : sha1 of original image.
*/
var sha1: String = "",
/**
* date: Creation date of the image to show it inside the bubble during bubble scroll.
*/
var date: String = ""
var date: String = "",
) : Parcelable {
/**
default parcelable constructor.
*/
constructor(parcel: Parcel) :
this(
parcel.readLong(),
parcel.readString()!!,
parcel.readParcelable(Uri::class.java.classLoader)!!,
parcel.readString()!!,
parcel.readLong(),
parcel.readString()!!,
parcel.readString()!!,
parcel.readString()!!,
)
/**
default parcelable constructor.
Write to parcel method.
*/
constructor(parcel: Parcel):
this(parcel.readLong(),
parcel.readString()!!,
parcel.readParcelable(Uri::class.java.classLoader)!!,
parcel.readString()!!,
parcel.readLong(),
parcel.readString()!!,
parcel.readString()!!,
parcel.readString()!!
)
/**
Write to parcel method.
*/
override fun writeToParcel(parcel: Parcel, flags: Int) {
override fun writeToParcel(
parcel: Parcel,
flags: Int,
) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeParcelable(uri, flags)
@ -81,41 +76,38 @@ data class Image(
/**
* Describe the kinds of special objects contained in this Parcelable
*/
override fun describeContents(): Int {
return 0
}
override fun describeContents(): Int = 0
/**
* Indicates whether some other object is "equal to" this one.
*/
override fun equals(other: Any?): Boolean {
if(javaClass != other?.javaClass) {
if (javaClass != other?.javaClass) {
return false
}
other as Image
if(id != other.id) {
return false;
if (id != other.id) {
return false
}
if(name != other.name) {
return false;
if (name != other.name) {
return false
}
if(uri != other.uri) {
return false;
if (uri != other.uri) {
return false
}
if(path != other.path) {
return false;
if (path != other.path) {
return false
}
if(bucketId != other.bucketId) {
return false;
if (bucketId != other.bucketId) {
return false
}
if(bucketName != other.bucketName) {
return false;
if (bucketName != other.bucketName) {
return false
}
if(sha1 != other.sha1) {
return false;
if (sha1 != other.sha1) {
return false
}
return true
@ -125,12 +117,8 @@ data class Image(
* Parcelable companion object
*/
companion object CREATOR : Parcelable.Creator<Image> {
override fun createFromParcel(parcel: Parcel): Image {
return Image(parcel)
}
override fun createFromParcel(parcel: Parcel): Image = Image(parcel)
override fun newArray(size: Int): Array<Image?> {
return arrayOfNulls(size)
}
override fun newArray(size: Int): Array<Image?> = arrayOfNulls(size)
}
}

View file

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

View file

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

View file

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

View file

@ -7,6 +7,8 @@ import androidx.recyclerview.widget.RecyclerView
/**
* Generic Recycler view adapter.
*/
abstract class RecyclerViewAdapter<T : RecyclerView.ViewHolder?>(val context: Context): RecyclerView.Adapter<T>() {
abstract class RecyclerViewAdapter<T : RecyclerView.ViewHolder?>(
val context: Context,
) : RecyclerView.Adapter<T>() {
val inflater: LayoutInflater = LayoutInflater.from(context)
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

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.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1
import kotlinx.coroutines.*
import java.util.*
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
* Image Loader class, loads images, depending on API results.
*/
class ImageLoader @Inject constructor(
/**
* MediaClient for SHA1 query.
*/
var mediaClient: MediaClient,
/**
* FileProcessor to pre-process the file.
*/
var fileProcessor: FileProcessor,
/**
* File Utils Wrapper for SHA1
*/
var fileUtilsWrapper: FileUtilsWrapper,
/**
* UploadedStatusDao for cache query.
*/
var uploadedStatusDao: UploadedStatusDao,
/**
* NotForUploadDao for database operations
*/
var notForUploadStatusDao: NotForUploadStatusDao,
/**
* Context for coroutine.
*/
val context: Context
) {
/**
* Maps to facilitate image query.
*/
private var mapModifiedImageSHA1: HashMap<Image, String> = HashMap()
private var mapHolderImage : HashMap<ImageViewHolder, Image> = HashMap()
private var mapResult: HashMap<String, Result> = HashMap()
private var mapImageSHA1: HashMap<Uri, String> = HashMap()
/**
* Coroutine Scope.
*/
private val scope : CoroutineScope = MainScope()
/**
* Query image and setUp the view.
*/
fun queryAndSetView(
holder: ImageViewHolder,
image: Image,
ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher,
uploadedContributionsList : List<Contribution>
class ImageLoader
@Inject
constructor(
/**
* MediaClient for SHA1 query.
*/
var mediaClient: MediaClient,
/**
* FileProcessor to pre-process the file.
*/
var fileProcessor: FileProcessor,
/**
* File Utils Wrapper for SHA1
*/
var fileUtilsWrapper: FileUtilsWrapper,
/**
* UploadedStatusDao for cache query.
*/
var uploadedStatusDao: UploadedStatusDao,
/**
* NotForUploadDao for database operations
*/
var notForUploadStatusDao: NotForUploadStatusDao,
/**
* Context for coroutine.
*/
val context: Context,
) {
/**
* Maps to facilitate image query.
*/
private var mapModifiedImageSHA1: HashMap<Image, String> = HashMap()
private var mapHolderImage: HashMap<ImageViewHolder, Image> = HashMap()
private var mapResult: HashMap<String, Result> = HashMap()
private var mapImageSHA1: HashMap<Uri, String> = HashMap()
/**
* Recycler view uses same view holder, so we can identify the latest query image from holder.
* Coroutine Scope.
*/
mapHolderImage[holder] = image
holder.itemNotUploaded()
holder.itemForUpload()
holder.itemNotUploading()
private val scope: CoroutineScope = MainScope()
scope.launch {
var result: Result = Result.NOTFOUND
/**
* Query image and setUp the view.
*/
fun queryAndSetView(
holder: ImageViewHolder,
image: Image,
ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher,
uploadedContributionsList: List<Contribution>,
) {
/**
* Recycler view uses same view holder, so we can identify the latest query image from holder.
*/
mapHolderImage[holder] = image
holder.itemNotUploaded()
holder.itemForUpload()
holder.itemNotUploading()
if (mapHolderImage[holder] != image) {
return@launch
}
scope.launch {
var result: Result = Result.NOTFOUND
val imageSHA1: String = when (mapImageSHA1[image.uri] != null) {
true -> mapImageSHA1[image.uri]!!
else -> CustomSelectorUtils.getImageSHA1(
image.uri,
ioDispatcher,
fileUtilsWrapper,
context.contentResolver
)
}
mapImageSHA1[image.uri] = imageSHA1
if (imageSHA1.isEmpty()) {
return@launch
}
val uploadedStatus = getFromUploaded(imageSHA1)
val sha1 = uploadedStatus?.let {
result = getResultFromUploadedStatus(uploadedStatus)
uploadedStatus.modifiedImageSHA1
} ?: run {
if (mapHolderImage[holder] == image) {
getSHA1(image, defaultDispatcher)
} else {
""
if (mapHolderImage[holder] != image) {
return@launch
}
}
if (mapHolderImage[holder] != image) {
return@launch
}
val imageSHA1: String =
when (mapImageSHA1[image.uri] != null) {
true -> mapImageSHA1[image.uri]!!
else ->
CustomSelectorUtils.getImageSHA1(
image.uri,
ioDispatcher,
fileUtilsWrapper,
context.contentResolver,
)
}
mapImageSHA1[image.uri] = imageSHA1
val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1)
if (imageSHA1.isEmpty()) {
return@launch
}
val uploadedStatus = getFromUploaded(imageSHA1)
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
when {
mapResult[imageSHA1] == null -> {
// Query original image.
result = checkWhetherFileExistsOnCommonsUsingSHA1(
imageSHA1,
ioDispatcher,
mediaClient
)
when (result) {
is Result.TRUE -> {
mapResult[imageSHA1] = Result.TRUE
}
is Result.ERROR -> {
mapResult[imageSHA1] = Result.ERROR
}
is Result.FALSE -> {
mapResult[imageSHA1] = Result.FALSE
}
is Result.INVALID -> {
mapResult[imageSHA1] = Result.INVALID
}
is Result.NOTFOUND -> {
mapResult[imageSHA1] = Result.NOTFOUND
}
val sha1 =
uploadedStatus?.let {
result = getResultFromUploadedStatus(uploadedStatus)
uploadedStatus.modifiedImageSHA1
} ?: run {
if (mapHolderImage[holder] == image) {
getSHA1(image, defaultDispatcher)
} else {
""
}
}
else -> {
result = mapResult[imageSHA1]!!
}
if (mapHolderImage[holder] != image) {
return@launch
}
if (result is Result.TRUE) {
// Original image found.
insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false)
} else {
val existsInNotForUploadTable = notForUploadStatusDao.find(imageSHA1)
if (result in arrayOf(Result.NOTFOUND, Result.INVALID) && sha1.isNotEmpty()) {
when {
mapResult[sha1] == null -> {
// Original image not found, query modified image.
result = checkWhetherFileExistsOnCommonsUsingSHA1(
sha1,
ioDispatcher,
mediaClient
)
mapResult[imageSHA1] == null -> {
// Query original image.
result =
checkWhetherFileExistsOnCommonsUsingSHA1(
imageSHA1,
ioDispatcher,
mediaClient,
)
when (result) {
is Result.TRUE -> {
mapResult[sha1] = Result.TRUE
mapResult[imageSHA1] = Result.TRUE
}
is Result.ERROR -> {
mapResult[sha1] = Result.ERROR
mapResult[imageSHA1] = Result.ERROR
}
is Result.FALSE -> {
mapResult[sha1] = Result.FALSE
mapResult[imageSHA1] = Result.FALSE
}
is Result.INVALID -> {
mapResult[sha1] = Result.INVALID
mapResult[imageSHA1] = Result.INVALID
}
is Result.NOTFOUND -> {
mapResult[sha1] = Result.NOTFOUND
mapResult[imageSHA1] = Result.NOTFOUND
}
}
}
else -> {
result = mapResult[sha1]!!
result = mapResult[imageSHA1]!!
}
}
if (result != Result.ERROR) {
insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE)
if (result is Result.TRUE) {
// Original image found.
insertIntoUploaded(imageSHA1, sha1, result is Result.TRUE, false)
} else {
when {
mapResult[sha1] == null -> {
// Original image not found, query modified image.
result =
checkWhetherFileExistsOnCommonsUsingSHA1(
sha1,
ioDispatcher,
mediaClient,
)
when (result) {
is Result.TRUE -> {
mapResult[sha1] = Result.TRUE
}
is Result.ERROR -> {
mapResult[sha1] = Result.ERROR
}
is Result.FALSE -> {
mapResult[sha1] = Result.FALSE
}
is Result.INVALID -> {
mapResult[sha1] = Result.INVALID
}
is Result.NOTFOUND -> {
mapResult[sha1] = Result.NOTFOUND
}
}
}
else -> {
result = mapResult[sha1]!!
}
}
if (result != Result.ERROR) {
insertIntoUploaded(imageSHA1, sha1, false, result is Result.TRUE)
}
}
}
}
val sharedPreferences: SharedPreferences =
context
.getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val showAlreadyActionedImages =
sharedPreferences.getBoolean(
ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY,
true
)
val sharedPreferences: SharedPreferences =
context
.getSharedPreferences(ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY, 0)
val showAlreadyActionedImages =
sharedPreferences.getBoolean(
ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY,
true,
)
if (mapHolderImage[holder] == image) {
if ((result is Result.TRUE) && showAlreadyActionedImages) {
holder.itemUploaded()
} else holder.itemNotUploaded()
if ((existsInNotForUploadTable > 0) && showAlreadyActionedImages) {
holder.itemNotForUpload()
} else holder.itemForUpload()
}
if (uploadedContributionsList.isNotEmpty()) {
for (contribution in uploadedContributionsList ) {
if (contribution.contentUri == image.uri && showAlreadyActionedImages) {
holder.itemUploading()
break
if (mapHolderImage[holder] == image) {
if ((result is Result.TRUE) && showAlreadyActionedImages) {
holder.itemUploaded()
} else {
holder.itemNotUploading()
holder.itemNotUploaded()
}
if ((existsInNotForUploadTable > 0) && showAlreadyActionedImages) {
holder.itemNotForUpload()
} else {
holder.itemForUpload()
}
}
if (uploadedContributionsList.isNotEmpty()) {
for (contribution in uploadedContributionsList) {
if (contribution.contentUri == image.uri && showAlreadyActionedImages) {
holder.itemUploading()
break
} else {
holder.itemNotUploading()
}
}
}
}
}
}
/**
* Finds out the next actionable image position
*/
suspend fun nextActionableImage(
allImages: List<Image>, ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher,
nextImagePosition: Int,
currentlyUploadingImages: List<Contribution>
): Int {
var next: Int
// Traversing from given position to the end
for (i in nextImagePosition until allImages.size){
val currentImage = allImages[i]
/**
* Finds out the next actionable image position
*/
suspend fun nextActionableImage(
allImages: List<Image>,
ioDispatcher: CoroutineDispatcher,
defaultDispatcher: CoroutineDispatcher,
nextImagePosition: Int,
currentlyUploadingImages: List<Contribution>,
): Int {
var next: Int
// Traversing from given position to the end
for (i in nextImagePosition until allImages.size) {
val currentImage = allImages[i]
if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) {
continue // Skip this image as it's currently being uploaded
}
if (currentlyUploadingImages.any { it.contentUri == currentImage.uri }) {
continue // Skip this image as it's currently being uploaded
}
val imageSHA1: String = when (mapImageSHA1[currentImage.uri] != null) {
true -> mapImageSHA1[currentImage.uri]!!
else -> CustomSelectorUtils.getImageSHA1(
currentImage.uri,
ioDispatcher,
fileUtilsWrapper,
context.contentResolver
)
}
next = notForUploadStatusDao.find(imageSHA1)
val imageSHA1: String =
when (mapImageSHA1[currentImage.uri] != null) {
true -> mapImageSHA1[currentImage.uri]!!
else ->
CustomSelectorUtils.getImageSHA1(
currentImage.uri,
ioDispatcher,
fileUtilsWrapper,
context.contentResolver,
)
}
next = notForUploadStatusDao.find(imageSHA1)
// After checking the image in the not for upload table, if the image is present then
// skips the image and moves to next image for checking
if(next > 0){
continue
// After checking the image in the not for upload table, if the image is present then
// skips the image and moves to next image for checking
if (next > 0) {
continue
// Otherwise checks in already uploaded table
} else {
next = uploadedStatusDao.findByImageSHA1(imageSHA1, true)
// Otherwise checks in already uploaded table
} else {
next = uploadedStatusDao.findByImageSHA1(imageSHA1, true)
// If the image is not present in the already uploaded table, checks for its
// modified SHA1 in already uploaded table
if (next <= 0) {
val modifiedImageSha1 = getSHA1(currentImage, defaultDispatcher)
next = uploadedStatusDao.findByModifiedImageSHA1(
modifiedImageSha1,
true
)
// If the modified image SHA1 is not present in the already uploaded table,
// returns the position as next actionable image position
// If the image is not present in the already uploaded table, checks for its
// modified SHA1 in already uploaded table
if (next <= 0) {
return i
val modifiedImageSha1 = getSHA1(currentImage, defaultDispatcher)
next =
uploadedStatusDao.findByModifiedImageSHA1(
modifiedImageSha1,
true,
)
// If present in the db then skips iteration for the image and moves to the next
// for checking
// If the modified image SHA1 is not present in the already uploaded table,
// returns the position as next actionable image position
if (next <= 0) {
return i
// If present in the db then skips iteration for the image and moves to the next
// for checking
} else {
continue
}
// If present in the db then skips iteration for the image and moves to the next
// for checking
} else {
continue
}
// If present in the db then skips iteration for the image and moves to the next
// for checking
} else {
continue
}
}
return -1
}
return -1
}
/**
* Get SHA1, return SHA1 if available, otherwise generate and store the SHA1.
*
* @return sha1 of the image
*/
suspend fun getSHA1(image: Image, defaultDispatcher: CoroutineDispatcher): String {
mapModifiedImageSHA1[image]?.let{
return it
}
val sha1 = CustomSelectorUtils
.generateModifiedSHA1(image,
defaultDispatcher,
context,
fileProcessor,
fileUtilsWrapper
)
mapModifiedImageSHA1[image] = sha1;
return sha1;
}
/**
* Get the uploaded status entry from the database.
*/
suspend fun getFromUploaded(imageSha1:String): UploadedStatus? {
return uploadedStatusDao.getUploadedFromImageSHA1(imageSha1)
}
/**
* Insert into uploaded status table.
*/
suspend fun insertIntoUploaded(imageSha1:String, modifiedImageSha1:String, imageResult:Boolean, modifiedImageResult: Boolean){
uploadedStatusDao.insertUploaded(
UploadedStatus(
imageSha1,
modifiedImageSha1,
imageResult,
modifiedImageResult
)
)
}
/**
* Get result data from database.
*/
fun getResultFromUploadedStatus(uploadedStatus: UploadedStatus): Result {
if (uploadedStatus.imageResult || uploadedStatus.modifiedImageResult) {
return Result.TRUE
} else {
uploadedStatus.lastUpdated?.let {
val duration = Calendar.getInstance().time.time - it.time
if (TimeUnit.MILLISECONDS.toDays(duration) < INVALIDATE_DAY_COUNT) {
return Result.FALSE
}
}
}
return Result.INVALID
}
/**
* Sealed Result class.
*/
sealed class Result {
object TRUE : Result()
object FALSE : Result()
object INVALID : Result()
object NOTFOUND : Result()
object ERROR : Result()
}
/**
* Companion Object
*/
companion object {
/**
* Invalidate Day count.
* False Database Entries are invalid after INVALIDATE_DAY_COUNT and need to be re-queried.
* Get SHA1, return SHA1 if available, otherwise generate and store the SHA1.
*
* @return sha1 of the image
*/
const val INVALIDATE_DAY_COUNT: Long = 7
}
suspend fun getSHA1(
image: Image,
defaultDispatcher: CoroutineDispatcher,
): String {
mapModifiedImageSHA1[image]?.let {
return it
}
val sha1 =
CustomSelectorUtils
.generateModifiedSHA1(
image,
defaultDispatcher,
context,
fileProcessor,
fileUtilsWrapper,
)
mapModifiedImageSHA1[image] = sha1
return sha1
}
}
/**
* Get the uploaded status entry from the database.
*/
suspend fun getFromUploaded(imageSha1: String): UploadedStatus? = uploadedStatusDao.getUploadedFromImageSHA1(imageSha1)
/**
* Insert into uploaded status table.
*/
suspend fun insertIntoUploaded(
imageSha1: String,
modifiedImageSha1: String,
imageResult: Boolean,
modifiedImageResult: Boolean,
) {
uploadedStatusDao.insertUploaded(
UploadedStatus(
imageSha1,
modifiedImageSha1,
imageResult,
modifiedImageResult,
),
)
}
/**
* Get result data from database.
*/
fun getResultFromUploadedStatus(uploadedStatus: UploadedStatus): Result {
if (uploadedStatus.imageResult || uploadedStatus.modifiedImageResult) {
return Result.TRUE
} else {
uploadedStatus.lastUpdated?.let {
val duration = Calendar.getInstance().time.time - it.time
if (TimeUnit.MILLISECONDS.toDays(duration) < INVALIDATE_DAY_COUNT) {
return Result.FALSE
}
}
}
return Result.INVALID
}
/**
* Sealed Result class.
*/
sealed class Result {
object TRUE : Result()
object FALSE : Result()
object INVALID : Result()
object NOTFOUND : Result()
object ERROR : Result()
}
/**
* Companion Object
*/
companion object {
/**
* Invalidate Day count.
* False Database Entries are invalid after INVALIDATE_DAY_COUNT and need to be re-queried.
*/
const val INVALIDATE_DAY_COUNT: Long = 7
}
}

View file

@ -5,7 +5,10 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import fr.free.nrw.commons.contributions.Contribution
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.PlaceDao
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
*
*/
@Database(entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class], version = 18, exportSchema = false)
@Database(
entities = [Contribution::class, Depicts::class, UploadedStatus::class, NotForUploadStatus::class, ReviewEntity::class, Place::class],
version = 18,
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun contributionDao(): ContributionDao
abstract fun PlaceDao(): PlaceDao
abstract fun DepictsDao(): DepictsDao;
abstract fun UploadedStatusDao(): UploadedStatusDao;
abstract fun DepictsDao(): DepictsDao
abstract fun UploadedStatusDao(): UploadedStatusDao
abstract fun NotForUploadStatusDao(): NotForUploadStatusDao
abstract fun ReviewDao(): ReviewDao
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,6 @@ import java.io.File
* implementations to provide specific functionality for tasks like rotating images.
*/
interface TransformImage {
/**
* Rotates the specified image file by the given degree.
*
@ -17,5 +16,8 @@ interface TransformImage {
* @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails.
*/
fun rotateImage(imageFile: File, degree : Int ):File?
fun rotateImage(
imageFile: File,
degree: Int,
): File?
}

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
* the input image file, performs the rotation, and saves the rotated image to a new file.
*/
class TransformImageImpl() : TransformImage {
class TransformImageImpl : TransformImage {
/**
* Rotates the specified image file by the given degree.
*
@ -24,46 +23,50 @@ class TransformImageImpl() : TransformImage {
* @param degree The degree by which to rotate the image.
* @return The rotated image File, or null if the rotation operation fails.
*/
override fun rotateImage(imageFile: File, degree : Int): File? {
override fun rotateImage(
imageFile: File,
degree: Int,
): File? {
Timber.tag("Trying to rotate image").d("Starting")
val path = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
)
val path =
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS,
)
val imagePath = System.currentTimeMillis()
val file: File = File(path, "$imagePath.jpg")
val output = file
val rotated = try {
val lljTran = LLJTran(imageFile)
lljTran.read(
LLJTran.READ_ALL,
false,
) // This could throw an LLJTranException. I am not catching it for now... Let's see.
lljTran.transform(
when(degree){
90 -> LLJTran.ROT_90
180 -> LLJTran.ROT_180
270 -> LLJTran.ROT_270
else -> {
LLJTran.ROT_90
}
},
LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION
)
BufferedOutputStream(FileOutputStream(output)).use { writer ->
lljTran.save(writer, LLJTran.OPT_WRITE_ALL )
val rotated =
try {
val lljTran = LLJTran(imageFile)
lljTran.read(
LLJTran.READ_ALL,
false,
) // This could throw an LLJTranException. I am not catching it for now... Let's see.
lljTran.transform(
when (degree) {
90 -> LLJTran.ROT_90
180 -> LLJTran.ROT_180
270 -> LLJTran.ROT_270
else -> {
LLJTran.ROT_90
}
},
LLJTran.OPT_DEFAULTS or LLJTran.OPT_XFORM_ORIENTATION,
)
BufferedOutputStream(FileOutputStream(output)).use { writer ->
lljTran.save(writer, LLJTran.OPT_WRITE_ALL)
}
lljTran.freeMemory()
true
} catch (e: LLJTranException) {
Timber.tag("Error").d(e)
return null
false
}
lljTran.freeMemory()
true
} catch (e: LLJTranException) {
Timber.tag("Error").d(e)
return null
false
}
if (rotated) {
Timber.tag("Done rotating image").d("Done")

View file

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

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.SubCategoriesPresenterImpl
@Module
abstract class CategoriesModule {
@Binds
abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter(): CategoryMediaPresenter
@Binds
abstract fun CategoryMediaPresenterImpl.bindsCategoryMediaPresenter()
: CategoryMediaPresenter
abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter(): SubCategoriesPresenter
@Binds
abstract fun SubCategoriesPresenterImpl.bindsSubCategoriesPresenter()
: SubCategoriesPresenter
@Binds
abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter()
: ParentCategoriesPresenter
abstract fun ParentCategoriesPresenterImpl.bindsParentCategoriesPresenter(): ParentCategoriesPresenter
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -4,7 +4,6 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.explore.paging.BasePagingFragment
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
abstract class PageableDepictionsFragment : BasePagingFragment<DepictedItem>() {
override val errorTextId: Int = R.string.error_loading_depictions
override val pagedListAdapter by lazy {

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