Merge branch 'main' into 1-issue#5829

This commit is contained in:
Nicolas Raoul 2024-12-13 23:18:19 +09:00 committed by GitHub
commit 7631dfb57b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
656 changed files with 26512 additions and 26467 deletions

View file

@ -1,16 +1,12 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ClassWithOnlyPrivateConstructors" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="ClassWithOnlyPrivateConstructors" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ConfusingElse" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="ConfusingElse" enabled="true" level="WARNING" enabled_by_default="true">
<option name="reportWhenNoStatementFollow" value="true" /> <option name="reportWhenNoStatementFollow" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="ControlFlowStatementWithoutBraces" enabled="true" level="ERROR" enabled_by_default="true" /> <inspection_tool class="ControlFlowStatementWithoutBraces" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="DefaultNotLastCaseInSwitch" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ExplicitThis" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="ExplicitThis" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="FieldMayBeFinal" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="LocalCanBeFinal" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="LocalCanBeFinal" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_VARIABLES" value="true" /> <option name="REPORT_VARIABLES" value="true" />
<option name="REPORT_PARAMETERS" value="true" /> <option name="REPORT_PARAMETERS" value="true" />
@ -25,13 +21,11 @@
<option name="ignoreInMatchingInstanceof" value="false" /> <option name="ignoreInMatchingInstanceof" value="false" />
</inspection_tool> </inspection_tool>
<inspection_tool class="ProblematicWhitespace" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="ProblematicWhitespace" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ProtectedMemberInFinalClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="RedundantImplements" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="RedundantImplements" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoreSerializable" value="false" /> <option name="ignoreSerializable" value="false" />
<option name="ignoreCloneable" value="false" /> <option name="ignoreCloneable" value="false" />
</inspection_tool> </inspection_tool>
<inspection_tool class="RedundantMethodOverride" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="TypeParameterExtendsFinalClass" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="TypeParameterExtendsFinalClass" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarilyQualifiedStaticUsage" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="UnnecessarilyQualifiedStaticUsage" enabled="true" level="WARNING" enabled_by_default="true">
@ -47,6 +41,5 @@
<inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryThis" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UnnecessaryThis" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnnecessaryToStringCall" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>
</component> </component>

View file

@ -47,18 +47,19 @@ dependencies {
implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.jakewharton.timber:timber:4.7.1'
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0' implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
implementation "com.google.android.material:material:1.9.0" implementation "com.google.android.material:material:1.12.0"
implementation 'com.karumi:dexter:5.0.0' implementation 'com.karumi:dexter:5.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Jetpack Compose // Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.08.00') def composeBom = platform('androidx.compose:compose-bom:2024.11.00')
implementation "androidx.activity:activity-compose:1.9.1" implementation "androidx.activity:activity-compose:1.9.3"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
implementation (composeBom) implementation (composeBom)
implementation "androidx.compose.runtime:runtime" implementation "androidx.compose.runtime:runtime"
implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-viewbinding"
implementation "androidx.compose.ui:ui-graphics" implementation "androidx.compose.ui:ui-graphics"
implementation "androidx.compose.ui:ui-tooling" implementation "androidx.compose.ui:ui-tooling"
implementation "androidx.compose.foundation:foundation" implementation "androidx.compose.foundation:foundation"
@ -98,6 +99,7 @@ dependencies {
testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation 'org.mockito:mockito-core:5.6.0'
testImplementation "org.powermock:powermock-module-junit4:2.0.9" testImplementation "org.powermock:powermock-module-junit4:2.0.9"
testImplementation "org.powermock:powermock-api-mockito2:2.0.9" testImplementation "org.powermock:powermock-api-mockito2:2.0.9"
testImplementation("io.mockk:mockk:1.13.5")
// Unit testing // Unit testing
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
@ -137,7 +139,7 @@ dependencies {
implementation "androidx.browser:browser:1.3.0" implementation "androidx.browser:browser:1.3.0"
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation "androidx.exifinterface:exifinterface:1.3.2" implementation 'androidx.exifinterface:exifinterface:1.3.7'
implementation "androidx.core:core-ktx:$CORE_KTX_VERSION" implementation "androidx.core:core-ktx:$CORE_KTX_VERSION"
implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1' implementation 'com.simplecityapps:recyclerview-fastscroll:2.0.1'
@ -226,7 +228,7 @@ android {
excludes += ['META-INF/androidx.*'] excludes += ['META-INF/androidx.*']
} }
resources { resources {
excludes += ['META-INF/androidx.*', 'META-INF/proguard/androidx-annotations.pro'] excludes += ['META-INF/androidx.*', 'META-INF/proguard/androidx-annotations.pro', '/META-INF/LICENSE.md', '/META-INF/LICENSE-notice.md']
} }
} }
@ -312,6 +314,7 @@ android {
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.org/wiki/Special:PasswordReset\""
buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\""
buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.modifications.contentprovider\""
@ -347,6 +350,7 @@ android {
buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\"" buildConfigField "String", "SIGNUP_SUCCESS_REDIRECTION_URL", "\"https://commons.m.wikimedia.beta.wmflabs.org/w/index.php?title=Main_Page&welcome=yes\""
buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\"" buildConfigField "String", "FORGOT_PASSWORD_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/Special:PasswordReset\""
buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\"" buildConfigField "String", "PRIVACY_POLICY_URL", "\"https://github.com/commons-app/commons-app-documentation/blob/master/android/Privacy-policy.md\""
buildConfigField "String", "FILE_USAGES_BASE_URL", "\"https://commons.wikimedia.org/w/api.php?action=query&format=json&formatversion=2\""
buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\"" buildConfigField "String", "ACCOUNT_TYPE", "\"fr.free.nrw.commons.beta\""
buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\"" buildConfigField "String", "CONTRIBUTION_AUTHORITY", "\"fr.free.nrw.commons.beta.contributions.contentprovider\""
buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\"" buildConfigField "String", "MODIFICATION_AUTHORITY", "\"fr.free.nrw.commons.beta.modifications.contentprovider\""
@ -366,11 +370,11 @@ android {
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_11 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "17"
} }
buildToolsVersion buildToolsVersion buildToolsVersion buildToolsVersion
@ -380,7 +384,7 @@ android {
compose true compose true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion '1.3.2' kotlinCompilerExtensionVersion '1.5.8'
} }
namespace 'fr.free.nrw.commons' namespace 'fr.free.nrw.commons'
lint { lint {

View file

@ -105,7 +105,7 @@ class AboutActivityTest {
fun testLaunchTranslate() { fun testLaunchTranslate() {
Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(R.id.about_translate)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click()) Espresso.onView(ViewMatchers.withId(android.R.id.button1)).perform(ViewActions.click())
val langCode = CommonsApplication.getInstance().languageLookUpTable.codes[0] val langCode = CommonsApplication.instance.languageLookUpTable!!.getCodes()[0]
Intents.intended( Intents.intended(
CoreMatchers.allOf( CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_VIEW), IntentMatchers.hasAction(Intent.ACTION_VIEW),

View file

@ -18,7 +18,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule import androidx.test.rule.ActivityTestRule
import androidx.test.rule.GrantPermissionRule import androidx.test.rule.GrantPermissionRule
import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDevice
import fr.free.nrw.commons.LocationPicker.LocationPickerActivity import fr.free.nrw.commons.locationpicker.LocationPickerActivity
import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition import fr.free.nrw.commons.UITestHelper.Companion.childAtPosition
import fr.free.nrw.commons.auth.LoginActivity import fr.free.nrw.commons.auth.LoginActivity
import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers

View file

@ -17,7 +17,7 @@ class PasteSensitiveTextInputEditTextTest {
@Before @Before
fun setup() { fun setup() {
context = ApplicationProvider.getApplicationContext() context = ApplicationProvider.getApplicationContext()
textView = PasteSensitiveTextInputEditText(context) textView = PasteSensitiveTextInputEditText(context!!)
} }
// this test has no real value, just % for test code coverage // this test has no real value, just % for test code coverage

View file

@ -99,7 +99,6 @@
android:exported="true" android:exported="true"
android:hardwareAccelerated="false" android:hardwareAccelerated="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter android:label="@string/intent_share_upload_label"> <intent-filter android:label="@string/intent_share_upload_label">
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@ -122,7 +121,7 @@
android:name=".contributions.MainActivity" android:name=".contributions.MainActivity"
android:configChanges="screenSize|keyboard|orientation" android:configChanges="screenSize|keyboard|orientation"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" /> />
<activity <activity
android:name=".settings.SettingsActivity" android:name=".settings.SettingsActivity"
android:label="@string/title_activity_settings" /> android:label="@string/title_activity_settings" />
@ -172,7 +171,7 @@
android:name=".review.ReviewActivity" android:name=".review.ReviewActivity"
android:label="@string/title_activity_review" /> android:label="@string/title_activity_review" />
<activity <activity
android:name=".LocationPicker.LocationPickerActivity" android:name=".locationpicker.LocationPickerActivity"
android:label="Location Picker" /> android:label="Location Picker" />
<service <service

View file

@ -180,8 +180,8 @@ public class AboutActivity extends BaseActivity {
getString(R.string.about_translate_cancel), getString(R.string.about_translate_cancel),
positiveButtonRunnable, positiveButtonRunnable,
() -> {}, () -> {},
spinner, spinner
true); );
} }
} }

View file

@ -46,7 +46,7 @@ class BaseMarker {
val drawable: Drawable = context.resources.getDrawable(drawableResId) val drawable: Drawable = context.resources.getDrawable(drawableResId)
icon = icon =
if (drawable is BitmapDrawable) { if (drawable is BitmapDrawable) {
(drawable as BitmapDrawable).bitmap drawable.bitmap
} else { } else {
val bitmap = val bitmap =
Bitmap.createBitmap( Bitmap.createBitmap(

View file

@ -1,433 +0,0 @@
package fr.free.nrw.commons;
import static fr.free.nrw.commons.data.DBOpenHelper.CONTRIBUTIONS_TABLE;
import static org.acra.ReportField.ANDROID_VERSION;
import static org.acra.ReportField.APP_VERSION_CODE;
import static org.acra.ReportField.APP_VERSION_NAME;
import static org.acra.ReportField.PHONE_MODEL;
import static org.acra.ReportField.STACK_TRACE;
import static org.acra.ReportField.USER_COMMENT;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.os.Build;
import android.os.Process;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.multidex.MultiDexApplication;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.imagepipeline.core.ImagePipeline;
import com.facebook.imagepipeline.core.ImagePipelineConfig;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao.Table;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler;
import fr.free.nrw.commons.concurrency.ThreadPoolService;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.language.AppLanguageLookUpTable;
import fr.free.nrw.commons.logging.FileLoggingTree;
import fr.free.nrw.commons.logging.LogUtils;
import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.FileUtils;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar;
import io.reactivex.Completable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.internal.functions.Functions;
import io.reactivex.plugins.RxJavaPlugins;
import io.reactivex.schedulers.Schedulers;
import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Named;
import org.acra.ACRA;
import org.acra.annotation.AcraCore;
import org.acra.annotation.AcraDialog;
import org.acra.annotation.AcraMailSender;
import org.acra.data.StringFormat;
import timber.log.Timber;
@AcraCore(
buildConfigClass = BuildConfig.class,
resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
reportFormat = StringFormat.KEY_VALUE_LIST,
reportContent = {USER_COMMENT, APP_VERSION_CODE, APP_VERSION_NAME, ANDROID_VERSION, PHONE_MODEL,
STACK_TRACE}
)
@AcraMailSender(
mailTo = "commons-app-android-private@googlegroups.com",
reportAsFile = false
)
@AcraDialog(
resTheme = R.style.Theme_AppCompat_Dialog,
resText = R.string.crash_dialog_text,
resTitle = R.string.crash_dialog_title,
resCommentPrompt = R.string.crash_dialog_comment_prompt
)
public class CommonsApplication extends MultiDexApplication {
public static final String loginMessageIntentKey = "loginMessage";
public static final String loginUsernameIntentKey = "loginUsername";
public static final String IS_LIMITED_CONNECTION_MODE_ENABLED = "is_limited_connection_mode_enabled";
@Inject
SessionManager sessionManager;
@Inject
DBOpenHelper dbOpenHelper;
@Inject
@Named("default_preferences")
JsonKvStore defaultPrefs;
@Inject
CommonsCookieJar cookieJar;
@Inject
CustomOkHttpNetworkFetcher customOkHttpNetworkFetcher;
/**
* Constants begin
*/
public static final int OPEN_APPLICATION_DETAIL_SETTINGS = 1001;
public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using [[COM:MOA|Commons Mobile App]]";
public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App Feedback";
public static final String REPORT_EMAIL = "commons-app-android-private@googlegroups.com";
public static final String REPORT_EMAIL_SUBJECT = "Report a violation";
public static final String NOTIFICATION_CHANNEL_ID_ALL = "CommonsNotificationAll";
public static final String FEEDBACK_EMAIL_TEMPLATE_HEADER = "-- Technical information --";
/**
* Constants End
*/
private static CommonsApplication INSTANCE;
public static CommonsApplication getInstance() {
return INSTANCE;
}
private AppLanguageLookUpTable languageLookUpTable;
public AppLanguageLookUpTable getLanguageLookUpTable() {
return languageLookUpTable;
}
@Inject
ContributionDao contributionDao;
public static Boolean isPaused = false;
/**
* Used to declare and initialize various components and dependencies
*/
@Override
public void onCreate() {
super.onCreate();
INSTANCE = this;
ACRA.init(this);
ApplicationlessInjection
.getInstance(this)
.getCommonsApplicationComponent()
.inject(this);
initTimber();
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
Set<String> defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS);
if (null == defaultExifTagsSet) {
defaultExifTagsSet = new HashSet<>();
}
defaultExifTagsSet.add(getString(R.string.exif_tag_location));
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet);
}
// Set DownsampleEnabled to True to downsample the image in case it's heavy
ImagePipelineConfig config = ImagePipelineConfig.newBuilder(this)
.setNetworkFetcher(customOkHttpNetworkFetcher)
.setDownsampleEnabled(true)
.build();
try {
Fresco.initialize(this, config);
} catch (Exception e) {
Timber.e(e);
// TODO: Remove when we're able to initialize Fresco in test builds.
}
createNotificationChannel(this);
languageLookUpTable = new AppLanguageLookUpTable(this);
// This handler will catch exceptions thrown from Observables after they are disposed,
// or from Observables that are (deliberately or not) missing an onError handler.
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer());
// Fire progress callbacks for every 3% of uploaded content
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0");
}
/**
* Plants debug and file logging tree. Timber lets you plant your own logging trees.
*/
private void initTimber() {
boolean isBeta = ConfigUtils.isBetaFlavour();
String logFileName =
isBeta ? "CommonsBetaAppLogs" : "CommonsAppLogs";
String logDirectory = LogUtils.getLogDirectory();
//Delete stale logs if they have exceeded the specified size
deleteStaleLogs(logFileName, logDirectory);
FileLoggingTree tree = new FileLoggingTree(
Log.VERBOSE,
logFileName,
logDirectory,
1000,
getFileLoggingThreadPool());
Timber.plant(tree);
Timber.plant(new Timber.DebugTree());
}
/**
* Deletes the logs zip file at the specified directory and file locations specified in the
* params
*
* @param logFileName
* @param logDirectory
*/
private void deleteStaleLogs(String logFileName, String logDirectory) {
try {
File file = new File(logDirectory + "/zip/" + logFileName + ".zip");
if (file.exists() && file.getTotalSpace() > 1000000) {// In Kbs
file.delete();
}
} catch (Exception e) {
Timber.e(e);
}
}
public static boolean isRoboUnitTest() {
return "robolectric".equals(Build.FINGERPRINT);
}
private ThreadPoolService getFileLoggingThreadPool() {
return new ThreadPoolService.Builder("file-logging-thread")
.setPriority(Process.THREAD_PRIORITY_LOWEST)
.setPoolSize(1)
.setExceptionHandler(new BackgroundPoolExceptionHandler())
.build();
}
public static void createNotificationChannel(@NonNull Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager manager = (NotificationManager) context
.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = manager
.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL);
if (channel == null) {
channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID_ALL,
context.getString(R.string.notifications_channel_name_all),
NotificationManager.IMPORTANCE_DEFAULT);
manager.createNotificationChannel(channel);
}
}
}
public String getUserAgent() {
return "Commons/" + ConfigUtils.getVersionNameWithSha(this)
+ " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE;
}
/**
* clears data of current application
*
* @param context Application context
* @param logoutListener Implementation of interface LogoutListener
*/
@SuppressLint("CheckResult")
public void clearApplicationData(Context context, LogoutListener logoutListener) {
File cacheDirectory = context.getCacheDir();
File applicationDirectory = new File(cacheDirectory.getParent());
if (applicationDirectory.exists()) {
String[] fileNames = applicationDirectory.list();
for (String fileName : fileNames) {
if (!fileName.equals("lib")) {
FileUtils.deleteFile(new File(applicationDirectory, fileName));
}
}
}
sessionManager.logout()
.andThen(Completable.fromAction(() -> cookieJar.clear()))
.andThen(Completable.fromAction(() -> {
Timber.d("All accounts have been removed");
clearImageCache();
//TODO: fix preference manager
defaultPrefs.clearAll();
defaultPrefs.putBoolean("firstrun", false);
updateAllDatabases();
}
))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(logoutListener::onLogoutComplete, Timber::e);
}
/**
* Clear all images cache held by Fresco
*/
private void clearImageCache() {
ImagePipeline imagePipeline = Fresco.getImagePipeline();
imagePipeline.clearCaches();
}
/**
* Deletes all tables and re-creates them.
*/
private void updateAllDatabases() {
dbOpenHelper.getReadableDatabase().close();
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
CategoryDao.Table.onDelete(db);
dbOpenHelper.deleteTable(db,
CONTRIBUTIONS_TABLE);//Delete the contributions table in the existing db on older versions
try {
contributionDao.deleteAll();
} catch (SQLiteException e) {
Timber.e(e);
}
BookmarkPicturesDao.Table.onDelete(db);
BookmarkLocationsDao.Table.onDelete(db);
Table.onDelete(db);
}
/**
* Interface used to get log-out events
*/
public interface LogoutListener {
void onLogoutComplete();
}
/**
* This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
* with relevant intent parameters. It does not perform the actual logout operation.
*/
public static class BaseLogoutListener implements CommonsApplication.LogoutListener {
Context ctx;
String loginMessage, userName;
/**
* Constructor for BaseLogoutListener.
*
* @param ctx Application context
*/
public BaseLogoutListener(final Context ctx) {
this.ctx = ctx;
}
/**
* Constructor for BaseLogoutListener
*
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page
* @param loginUsername Username to be pre-filled on the login page
*/
public BaseLogoutListener(final Context ctx, final String loginMessage,
final String loginUsername) {
this.ctx = ctx;
this.loginMessage = loginMessage;
this.userName = loginUsername;
}
@Override
public void onLogoutComplete() {
Timber.d("Logout complete callback received.");
final Intent loginIntent = new Intent(ctx, LoginActivity.class);
loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (loginMessage != null) {
loginIntent.putExtra(loginMessageIntentKey, loginMessage);
}
if (userName != null) {
loginIntent.putExtra(loginUsernameIntentKey, userName);
}
ctx.startActivity(loginIntent);
}
}
/**
* This class is an extension of BaseLogoutListener, providing additional functionality or customization
* for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
*/
public static class ActivityLogoutListener extends BaseLogoutListener {
Activity activity;
/**
* Constructor for ActivityLogoutListener.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
*/
public ActivityLogoutListener(final Activity activity, final Context ctx) {
super(ctx);
this.activity = activity;
}
/**
* Constructor for ActivityLogoutListener with additional parameters for the login screen.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page after logout.
* @param loginUsername Username to be pre-filled on the login page after logout.
*/
public ActivityLogoutListener(final Activity activity, final Context ctx,
final String loginMessage, final String loginUsername) {
super(activity, loginMessage, loginUsername);
this.activity = activity;
}
@Override
public void onLogoutComplete() {
super.onLogoutComplete();
activity.finish();
}
}
}

View file

@ -0,0 +1,414 @@
package fr.free.nrw.commons
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.database.sqlite.SQLiteException
import android.os.Build
import android.os.Process
import android.util.Log
import androidx.multidex.MultiDexApplication
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.imagepipeline.core.ImagePipelineConfig
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao
import fr.free.nrw.commons.category.CategoryDao
import fr.free.nrw.commons.concurrency.BackgroundPoolExceptionHandler
import fr.free.nrw.commons.concurrency.ThreadPoolService
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.language.AppLanguageLookUpTable
import fr.free.nrw.commons.logging.FileLoggingTree
import fr.free.nrw.commons.logging.LogUtils
import fr.free.nrw.commons.media.CustomOkHttpNetworkFetcher
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.FileUtils
import fr.free.nrw.commons.utils.ConfigUtils.getVersionNameWithSha
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.wikidata.cookies.CommonsCookieJar
import io.reactivex.Completable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.internal.functions.Functions
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import org.acra.ACRA.init
import org.acra.ReportField
import org.acra.annotation.AcraCore
import org.acra.annotation.AcraDialog
import org.acra.annotation.AcraMailSender
import org.acra.data.StringFormat
import timber.log.Timber
import timber.log.Timber.DebugTree
import java.io.File
import javax.inject.Inject
import javax.inject.Named
@AcraCore(
buildConfigClass = BuildConfig::class,
resReportSendSuccessToast = R.string.crash_dialog_ok_toast,
reportFormat = StringFormat.KEY_VALUE_LIST,
reportContent = [ReportField.USER_COMMENT, ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_NAME, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.STACK_TRACE]
)
@AcraMailSender(mailTo = "commons-app-android-private@googlegroups.com", reportAsFile = false)
@AcraDialog(
resTheme = R.style.Theme_AppCompat_Dialog,
resText = R.string.crash_dialog_text,
resTitle = R.string.crash_dialog_title,
resCommentPrompt = R.string.crash_dialog_comment_prompt
)
class CommonsApplication : MultiDexApplication() {
@Inject
lateinit var sessionManager: SessionManager
@Inject
lateinit var dbOpenHelper: DBOpenHelper
@Inject
@field:Named("default_preferences")
lateinit var defaultPrefs: JsonKvStore
@Inject
lateinit var cookieJar: CommonsCookieJar
@Inject
lateinit var customOkHttpNetworkFetcher: CustomOkHttpNetworkFetcher
var languageLookUpTable: AppLanguageLookUpTable? = null
private set
@Inject
lateinit var contributionDao: ContributionDao
/**
* Used to declare and initialize various components and dependencies
*/
override fun onCreate() {
super.onCreate()
instance = this
init(this)
ApplicationlessInjection
.getInstance(this)
.commonsApplicationComponent
.inject(this)
initTimber()
if (!defaultPrefs.getBoolean("has_user_manually_removed_location")) {
var defaultExifTagsSet = defaultPrefs.getStringSet(Prefs.MANAGED_EXIF_TAGS)
if (null == defaultExifTagsSet) {
defaultExifTagsSet = HashSet()
}
defaultExifTagsSet.add(getString(R.string.exif_tag_location))
defaultPrefs.putStringSet(Prefs.MANAGED_EXIF_TAGS, defaultExifTagsSet)
}
// Set DownsampleEnabled to True to downsample the image in case it's heavy
val config = ImagePipelineConfig.newBuilder(this)
.setNetworkFetcher(customOkHttpNetworkFetcher)
.setDownsampleEnabled(true)
.build()
try {
Fresco.initialize(this, config)
} catch (e: Exception) {
Timber.e(e)
// TODO: Remove when we're able to initialize Fresco in test builds.
}
createNotificationChannel(this)
languageLookUpTable = AppLanguageLookUpTable(this)
// This handler will catch exceptions thrown from Observables after they are disposed,
// or from Observables that are (deliberately or not) missing an onError handler.
RxJavaPlugins.setErrorHandler(Functions.emptyConsumer())
// Fire progress callbacks for every 3% of uploaded content
System.setProperty("in.yuvi.http.fluent.PROGRESS_TRIGGER_THRESHOLD", "3.0")
}
/**
* Plants debug and file logging tree. Timber lets you plant your own logging trees.
*/
private fun initTimber() {
val isBeta = isBetaFlavour
val logFileName =
if (isBeta) "CommonsBetaAppLogs" else "CommonsAppLogs"
val logDirectory = LogUtils.getLogDirectory()
//Delete stale logs if they have exceeded the specified size
deleteStaleLogs(logFileName, logDirectory)
val tree = FileLoggingTree(
Log.VERBOSE,
logFileName,
logDirectory,
1000,
fileLoggingThreadPool
)
Timber.plant(tree)
Timber.plant(DebugTree())
}
/**
* Deletes the logs zip file at the specified directory and file locations specified in the
* params
*
* @param logFileName
* @param logDirectory
*/
private fun deleteStaleLogs(logFileName: String, logDirectory: String) {
try {
val file = File("$logDirectory/zip/$logFileName.zip")
if (file.exists() && file.totalSpace > 1000000) { // In Kbs
file.delete()
}
} catch (e: Exception) {
Timber.e(e)
}
}
private val fileLoggingThreadPool: ThreadPoolService
get() = ThreadPoolService.Builder("file-logging-thread")
.setPriority(Process.THREAD_PRIORITY_LOWEST)
.setPoolSize(1)
.setExceptionHandler(BackgroundPoolExceptionHandler())
.build()
val userAgent: String
get() = ("Commons/" + this.getVersionNameWithSha()
+ " (https://mediawiki.org/wiki/Apps/Commons) Android/" + Build.VERSION.RELEASE)
/**
* clears data of current application
*
* @param context Application context
* @param logoutListener Implementation of interface LogoutListener
*/
@SuppressLint("CheckResult")
fun clearApplicationData(context: Context, logoutListener: LogoutListener) {
val cacheDirectory = context.cacheDir
val applicationDirectory = File(cacheDirectory.parent)
if (applicationDirectory.exists()) {
val fileNames = applicationDirectory.list()
for (fileName in fileNames) {
if (fileName != "lib") {
FileUtils.deleteFile(File(applicationDirectory, fileName))
}
}
}
sessionManager.logout()
.andThen(Completable.fromAction { cookieJar.clear() })
.andThen(Completable.fromAction {
Timber.d("All accounts have been removed")
clearImageCache()
//TODO: fix preference manager
defaultPrefs.clearAll()
defaultPrefs.putBoolean("firstrun", false)
updateAllDatabases()
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ logoutListener.onLogoutComplete() }, { t: Throwable? -> Timber.e(t) })
}
/**
* Clear all images cache held by Fresco
*/
private fun clearImageCache() {
val imagePipeline = Fresco.getImagePipeline()
imagePipeline.clearCaches()
}
/**
* Deletes all tables and re-creates them.
*/
private fun updateAllDatabases() {
dbOpenHelper.readableDatabase.close()
val db = dbOpenHelper.writableDatabase
CategoryDao.Table.onDelete(db)
dbOpenHelper.deleteTable(
db,
DBOpenHelper.CONTRIBUTIONS_TABLE
) //Delete the contributions table in the existing db on older versions
try {
contributionDao.deleteAll()
} catch (e: SQLiteException) {
Timber.e(e)
}
BookmarkPicturesDao.Table.onDelete(db)
BookmarkLocationsDao.Table.onDelete(db)
BookmarkItemsDao.Table.onDelete(db)
}
/**
* Interface used to get log-out events
*/
interface LogoutListener {
fun onLogoutComplete()
}
/**
* This listener is responsible for handling post-logout actions, specifically invoking the LoginActivity
* with relevant intent parameters. It does not perform the actual logout operation.
*/
open class BaseLogoutListener : LogoutListener {
var ctx: Context
var loginMessage: String? = null
var userName: String? = null
/**
* Constructor for BaseLogoutListener.
*
* @param ctx Application context
*/
constructor(ctx: Context) {
this.ctx = ctx
}
/**
* Constructor for BaseLogoutListener
*
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page
* @param loginUsername Username to be pre-filled on the login page
*/
constructor(
ctx: Context, loginMessage: String?,
loginUsername: String?
) {
this.ctx = ctx
this.loginMessage = loginMessage
this.userName = loginUsername
}
override fun onLogoutComplete() {
Timber.d("Logout complete callback received.")
val loginIntent = Intent(ctx, LoginActivity::class.java)
loginIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (loginMessage != null) {
loginIntent.putExtra(LOGIN_MESSAGE_INTENT_KEY, loginMessage)
}
if (userName != null) {
loginIntent.putExtra(LOGIN_USERNAME_INTENT_KEY, userName)
}
ctx.startActivity(loginIntent)
}
}
/**
* This class is an extension of BaseLogoutListener, providing additional functionality or customization
* for the logout process. It includes specific actions to be taken during logout, such as handling redirection to the login screen.
*/
class ActivityLogoutListener : BaseLogoutListener {
var activity: Activity
/**
* Constructor for ActivityLogoutListener.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
*/
constructor(activity: Activity, ctx: Context) : super(ctx) {
this.activity = activity
}
/**
* Constructor for ActivityLogoutListener with additional parameters for the login screen.
*
* @param activity The activity context from which the logout is initiated. Used to perform actions such as finishing the activity.
* @param ctx The application context, used for invoking the LoginActivity and passing relevant intent parameters as part of the post-logout process.
* @param loginMessage Message to be displayed on the login page after logout.
* @param loginUsername Username to be pre-filled on the login page after logout.
*/
constructor(
activity: Activity, ctx: Context?,
loginMessage: String?, loginUsername: String?
) : super(activity, loginMessage, loginUsername) {
this.activity = activity
}
override fun onLogoutComplete() {
super.onLogoutComplete()
activity.finish()
}
}
companion object {
const val LOGIN_MESSAGE_INTENT_KEY: String = "loginMessage"
const val LOGIN_USERNAME_INTENT_KEY: String = "loginUsername"
const val IS_LIMITED_CONNECTION_MODE_ENABLED: String = "is_limited_connection_mode_enabled"
/**
* Constants begin
*/
const val OPEN_APPLICATION_DETAIL_SETTINGS: Int = 1001
const val DEFAULT_EDIT_SUMMARY: String = "Uploaded using [[COM:MOA|Commons Mobile App]]"
const val FEEDBACK_EMAIL: String = "commons-app-android@googlegroups.com"
const val FEEDBACK_EMAIL_SUBJECT: String = "Commons Android App Feedback"
const val REPORT_EMAIL: String = "commons-app-android-private@googlegroups.com"
const val REPORT_EMAIL_SUBJECT: String = "Report a violation"
const val NOTIFICATION_CHANNEL_ID_ALL: String = "CommonsNotificationAll"
const val FEEDBACK_EMAIL_TEMPLATE_HEADER: String = "-- Technical information --"
/**
* Constants End
*/
@JvmStatic
lateinit var instance: CommonsApplication
private set
@JvmField
var isPaused: Boolean = false
@JvmStatic
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = context
.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
var channel = manager
.getNotificationChannel(NOTIFICATION_CHANNEL_ID_ALL)
if (channel == null) {
channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID_ALL,
context.getString(R.string.notifications_channel_name_all),
NotificationManager.IMPORTANCE_DEFAULT
)
manager.createNotificationChannel(channel)
}
}
}
}
}

View file

@ -1,77 +0,0 @@
package fr.free.nrw.commons.LocationPicker;
import android.app.Activity;
import android.content.Intent;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.Media;
/**
* Helper class for starting the activity
*/
public final class LocationPicker {
/**
* Getting camera position from the intent using constants
*
* @param data intent
* @return CameraPosition
*/
public static CameraPosition getCameraPosition(final Intent data) {
return data.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
}
public static class IntentBuilder {
private final Intent intent;
/**
* Creates a new builder that creates an intent to launch the place picker activity.
*/
public IntentBuilder() {
intent = new Intent();
}
/**
* Gets and puts location in intent
* @param position CameraPosition
* @return LocationPicker.IntentBuilder
*/
public LocationPicker.IntentBuilder defaultLocation(
final CameraPosition position) {
intent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION, position);
return this;
}
/**
* Gets and puts activity name in intent
* @param activity activity key
* @return LocationPicker.IntentBuilder
*/
public LocationPicker.IntentBuilder activityKey(
final String activity) {
intent.putExtra(LocationPickerConstants.ACTIVITY_KEY, activity);
return this;
}
/**
* Gets and puts media in intent
* @param media Media
* @return LocationPicker.IntentBuilder
*/
public LocationPicker.IntentBuilder media(
final Media media) {
intent.putExtra(LocationPickerConstants.MEDIA, media);
return this;
}
/**
* Gets and sets the activity
* @param activity Activity
* @return Intent
*/
public Intent build(final Activity activity) {
intent.setClass(activity, LocationPickerActivity.class);
return intent;
}
}
}

View file

@ -1,679 +0,0 @@
package fr.free.nrw.commons.LocationPicker;
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_LOCATION;
import static fr.free.nrw.commons.upload.mediaDetails.UploadMediaDetailFragment.LAST_ZOOM;
import static fr.free.nrw.commons.utils.MapUtils.ZOOM_LEVEL;
import android.Manifest.permission;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.location.LocationManager;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.text.Html;
import android.text.method.LinkMovementMethod;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.animation.OvershootInterpolator;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import fr.free.nrw.commons.CameraPosition;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient;
import fr.free.nrw.commons.coordinates.CoordinateEditHelper;
import fr.free.nrw.commons.filepicker.Constants;
import fr.free.nrw.commons.kvstore.BasicKvStore;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationPermissionsHelper;
import fr.free.nrw.commons.location.LocationPermissionsHelper.LocationPermissionCallback;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DialogUtil;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import org.osmdroid.tileprovider.tilesource.TileSourceFactory;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.util.constants.GeoConstants;
import org.osmdroid.views.CustomZoomButtonsController;
import org.osmdroid.views.overlay.Marker;
import org.osmdroid.views.overlay.Overlay;
import org.osmdroid.views.overlay.ScaleDiskOverlay;
import org.osmdroid.views.overlay.TilesOverlay;
import timber.log.Timber;
/**
* Helps to pick location and return the result with an intent
*/
public class LocationPickerActivity extends BaseActivity implements
LocationPermissionCallback {
/**
* coordinateEditHelper: helps to edit coordinates
*/
@Inject
CoordinateEditHelper coordinateEditHelper;
/**
* media : Media object
*/
private Media media;
/**
* cameraPosition : position of picker
*/
private CameraPosition cameraPosition;
/**
* markerImage : picker image
*/
private ImageView markerImage;
/**
* mapView : OSM Map
*/
private org.osmdroid.views.MapView mapView;
/**
* tvAttribution : credit
*/
private AppCompatTextView tvAttribution;
/**
* activity : activity key
*/
private String activity;
/**
* modifyLocationButton : button for start editing location
*/
Button modifyLocationButton;
/**
* removeLocationButton : button to remove location metadata
*/
Button removeLocationButton;
/**
* showInMapButton : button for showing in map
*/
TextView showInMapButton;
/**
* placeSelectedButton : fab for selecting location
*/
FloatingActionButton placeSelectedButton;
/**
* fabCenterOnLocation: button for center on location;
*/
FloatingActionButton fabCenterOnLocation;
/**
* shadow : imageview of shadow
*/
private ImageView shadow;
/**
* largeToolbarText : textView of shadow
*/
private TextView largeToolbarText;
/**
* smallToolbarText : textView of shadow
*/
private TextView smallToolbarText;
/**
* applicationKvStore : for storing values
*/
@Inject
@Named("default_preferences")
public
JsonKvStore applicationKvStore;
BasicKvStore store;
/**
* isDarkTheme: for keeping a track of the device theme and modifying the map theme accordingly
*/
@Inject
SystemThemeUtils systemThemeUtils;
private boolean isDarkTheme;
private boolean moveToCurrentLocation;
@Inject
LocationServiceManager locationManager;
LocationPermissionsHelper locationPermissionsHelper;
@Inject
SessionManager sessionManager;
/**
* Constants
*/
private static final String CAMERA_POS = "cameraPosition";
private static final String ACTIVITY = "activity";
@SuppressLint("ClickableViewAccessibility")
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
super.onCreate(savedInstanceState);
isDarkTheme = systemThemeUtils.isDeviceInNightMode();
moveToCurrentLocation = false;
store = new BasicKvStore(this, "LocationPermissions");
getWindow().requestFeature(Window.FEATURE_ACTION_BAR);
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
setContentView(R.layout.activity_location_picker);
if (savedInstanceState == null) {
cameraPosition = getIntent()
.getParcelableExtra(LocationPickerConstants.MAP_CAMERA_POSITION);
activity = getIntent().getStringExtra(LocationPickerConstants.ACTIVITY_KEY);
media = getIntent().getParcelableExtra(LocationPickerConstants.MEDIA);
}else{
cameraPosition = savedInstanceState.getParcelable(CAMERA_POS);
activity = savedInstanceState.getString(ACTIVITY);
media = savedInstanceState.getParcelable("sMedia");
}
bindViews();
addBackButtonListener();
addPlaceSelectedButton();
addCredits();
getToolbarUI();
addCenterOnGPSButton();
org.osmdroid.config.Configuration.getInstance().load(getApplicationContext(),
PreferenceManager.getDefaultSharedPreferences(getApplicationContext()));
mapView.setTileSource(TileSourceFactory.WIKIMEDIA);
mapView.setTilesScaledToDpi(true);
mapView.setMultiTouchControls(true);
org.osmdroid.config.Configuration.getInstance().getAdditionalHttpRequestProperties().put(
"Referer", "http://maps.wikimedia.org/"
);
mapView.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER);
mapView.getController().setZoom(ZOOM_LEVEL);
mapView.setOnTouchListener((v, event) -> {
if (event.getAction() == MotionEvent.ACTION_MOVE) {
if (markerImage.getTranslationY() == 0) {
markerImage.animate().translationY(-75)
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
}
} else if (event.getAction() == MotionEvent.ACTION_UP) {
markerImage.animate().translationY(0)
.setInterpolator(new OvershootInterpolator()).setDuration(250).start();
}
return false;
});
if ("UploadActivity".equals(activity)) {
placeSelectedButton.setVisibility(View.GONE);
modifyLocationButton.setVisibility(View.VISIBLE);
removeLocationButton.setVisibility(View.VISIBLE);
showInMapButton.setVisibility(View.VISIBLE);
largeToolbarText.setText(getResources().getString(R.string.image_location));
smallToolbarText.setText(getResources().
getString(R.string.check_whether_location_is_correct));
fabCenterOnLocation.setVisibility(View.GONE);
markerImage.setVisibility(View.GONE);
shadow.setVisibility(View.GONE);
assert cameraPosition != null;
showSelectedLocationMarker(new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude()));
}
setupMapView();
}
/**
* Moves the center of the map to the specified coordinates
*
*/
private void moveMapTo(double latitude, double longitude){
if(mapView != null && mapView.getController() != null){
GeoPoint point = new GeoPoint(latitude, longitude);
mapView.getController().setCenter(point);
mapView.getController().animateTo(point);
}
}
/**
* Moves the center of the map to the specified coordinates
* @param point The GeoPoint object which contains the coordinates to move to
*/
private void moveMapTo(GeoPoint point){
if(point != null){
moveMapTo(point.getLatitude(), point.getLongitude());
}
}
/**
* For showing credits
*/
private void addCredits() {
tvAttribution.setText(Html.fromHtml(getString(R.string.map_attribution)));
tvAttribution.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* For setting up Dark Theme
*/
private void darkThemeSetup() {
if (isDarkTheme) {
shadow.setColorFilter(Color.argb(255, 255, 255, 255));
mapView.getOverlayManager().getTilesOverlay()
.setColorFilter(TilesOverlay.INVERT_COLORS);
}
}
/**
* Clicking back button destroy locationPickerActivity
*/
private void addBackButtonListener() {
final ImageView backButton = findViewById(R.id.maplibre_place_picker_toolbar_back_button);
backButton.setOnClickListener(v -> {
finish();
});
}
/**
* Binds mapView and location picker icon
*/
private void bindViews() {
mapView = findViewById(R.id.map_view);
markerImage = findViewById(R.id.location_picker_image_view_marker);
tvAttribution = findViewById(R.id.tv_attribution);
modifyLocationButton = findViewById(R.id.modify_location);
removeLocationButton = findViewById(R.id.remove_location);
showInMapButton = findViewById(R.id.show_in_map);
showInMapButton.setText(getResources().getString(R.string.show_in_map_app).toUpperCase());
shadow = findViewById(R.id.location_picker_image_view_shadow);
}
/**
* Gets toolbar color
*/
private void getToolbarUI() {
final ConstraintLayout toolbar = findViewById(R.id.location_picker_toolbar);
largeToolbarText = findViewById(R.id.location_picker_toolbar_primary_text_view);
smallToolbarText = findViewById(R.id.location_picker_toolbar_secondary_text_view);
toolbar.setBackgroundColor(getResources().getColor(R.color.primaryColor));
}
private void setupMapView() {
requestLocationPermissions();
//If location metadata is available, move map to that location.
if(activity.equals("UploadActivity") || activity.equals("MediaActivity")){
moveMapToMediaLocation();
} else {
//If location metadata is not available, move map to device GPS location.
moveMapToGPSLocation();
}
modifyLocationButton.setOnClickListener(v -> onClickModifyLocation());
removeLocationButton.setOnClickListener(v -> onClickRemoveLocation());
showInMapButton.setOnClickListener(v -> showInMapApp());
darkThemeSetup();
}
/**
* Handles onclick event of modifyLocationButton
*/
private void onClickModifyLocation() {
placeSelectedButton.setVisibility(View.VISIBLE);
modifyLocationButton.setVisibility(View.GONE);
removeLocationButton.setVisibility(View.GONE);
showInMapButton.setVisibility(View.GONE);
markerImage.setVisibility(View.VISIBLE);
shadow.setVisibility(View.VISIBLE);
largeToolbarText.setText(getResources().getString(R.string.choose_a_location));
smallToolbarText.setText(getResources().getString(R.string.pan_and_zoom_to_adjust));
fabCenterOnLocation.setVisibility(View.VISIBLE);
removeSelectedLocationMarker();
moveMapToMediaLocation();
}
/**
* Handles onclick event of removeLocationButton
*/
private void onClickRemoveLocation() {
DialogUtil.showAlertDialog(this,
getString(R.string.remove_location_warning_title),
getString(R.string.remove_location_warning_desc),
getString(R.string.continue_message),
getString(R.string.cancel), () -> removeLocationFromImage(), null);
}
/**
* Method to remove the location from the picture
*/
private void removeLocationFromImage() {
if (media != null) {
compositeDisposable.add(coordinateEditHelper.makeCoordinatesEdit(getApplicationContext()
, media, "0.0", "0.0", "0.0f")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(s -> {
Timber.d("Coordinates are removed from the image");
}));
}
final Intent returningIntent = new Intent();
setResult(AppCompatActivity.RESULT_OK, returningIntent);
finish();
}
/**
* Show the location in map app. Map will center on the location metadata, if available.
* If there is no location metadata, the map will center on the commons app map center.
*/
private void showInMapApp() {
fr.free.nrw.commons.location.LatLng position = null;
if(activity.equals("UploadActivity") && cameraPosition != null){
//location metadata is available
position = new fr.free.nrw.commons.location.LatLng(cameraPosition.getLatitude(),
cameraPosition.getLongitude(), 0.0f);
} else if(mapView != null){
//location metadata is not available
position = new fr.free.nrw.commons.location.LatLng(mapView.getMapCenter().getLatitude(),
mapView.getMapCenter().getLongitude(), 0.0f);
}
if(position != null){
Utils.handleGeoCoordinates(this, position);
}
}
/**
* Moves the center of the map to the media's location, if that data
* is available.
*/
private void moveMapToMediaLocation() {
if (cameraPosition != null) {
GeoPoint point = new GeoPoint(cameraPosition.getLatitude(),
cameraPosition.getLongitude());
moveMapTo(point);
}
}
/**
* Moves the center of the map to the device's GPS location, if that data is available.
*/
private void moveMapToGPSLocation(){
if(locationManager != null){
fr.free.nrw.commons.location.LatLng location = locationManager.getLastLocation();
if(location != null){
GeoPoint point = new GeoPoint(location.getLatitude(), location.getLongitude());
moveMapTo(point);
}
}
}
/**
* Select the preferable location
*/
private void addPlaceSelectedButton() {
placeSelectedButton = findViewById(R.id.location_chosen_button);
placeSelectedButton.setOnClickListener(view -> placeSelected());
}
/**
* Return the intent with required data
*/
void placeSelected() {
if (activity.equals("NoLocationUploadActivity")) {
applicationKvStore.putString(LAST_LOCATION,
mapView.getMapCenter().getLatitude()
+ ","
+ mapView.getMapCenter().getLongitude());
applicationKvStore.putString(LAST_ZOOM, mapView.getZoomLevel() + "");
}
if (media == null) {
final Intent returningIntent = new Intent();
returningIntent.putExtra(LocationPickerConstants.MAP_CAMERA_POSITION,
new CameraPosition(mapView.getMapCenter().getLatitude(),
mapView.getMapCenter().getLongitude(), 14.0));
setResult(AppCompatActivity.RESULT_OK, returningIntent);
} else {
updateCoordinates(String.valueOf(mapView.getMapCenter().getLatitude()),
String.valueOf(mapView.getMapCenter().getLongitude()),
String.valueOf(0.0f));
}
finish();
}
/**
* Fetched coordinates are replaced with existing coordinates by a POST API call.
* @param Latitude to be added
* @param Longitude to be added
* @param Accuracy to be added
*/
public void updateCoordinates(final String Latitude, final String Longitude,
final String Accuracy) {
if (media == null) {
return;
}
try {
compositeDisposable.add(
coordinateEditHelper.makeCoordinatesEdit(getApplicationContext(), media,
Latitude, Longitude, Accuracy)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(s -> {
Timber.d("Coordinates are added.");
}));
} catch (Exception e) {
if (e.getLocalizedMessage().equals(CsrfTokenClient.ANONYMOUS_TOKEN_MESSAGE)) {
final String username = sessionManager.getUserName();
final CommonsApplication.BaseLogoutListener logoutListener = new CommonsApplication.BaseLogoutListener(
this,
getString(R.string.invalid_login_message),
username
);
CommonsApplication.getInstance().clearApplicationData(
this, logoutListener);
}
}
}
/**
* Center the camera on the last saved location
*/
private void addCenterOnGPSButton() {
fabCenterOnLocation = findViewById(R.id.center_on_gps);
fabCenterOnLocation.setOnClickListener(view -> {
moveToCurrentLocation = true;
requestLocationPermissions();
});
}
/**
* Adds selected location marker on the map
*/
private void showSelectedLocationMarker(GeoPoint point) {
Drawable icon = ContextCompat.getDrawable(this, R.drawable.map_default_map_marker);
Marker marker = new Marker(mapView);
marker.setPosition(point);
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM);
marker.setIcon(icon);
marker.setInfoWindow(null);
mapView.getOverlays().add(marker);
mapView.invalidate();
}
/**
* Removes selected location marker from the map
*/
private void removeSelectedLocationMarker() {
List<Overlay> overlays = mapView.getOverlays();
for (int i = 0; i < overlays.size(); i++) {
if (overlays.get(i) instanceof Marker) {
Marker item = (Marker) overlays.get(i);
if (cameraPosition.getLatitude() == item.getPosition().getLatitude()
&& cameraPosition.getLongitude() == item.getPosition().getLongitude()) {
mapView.getOverlays().remove(i);
mapView.invalidate();
break;
}
}
}
}
/**
* Center the map at user's current location
*/
private void requestLocationPermissions() {
locationPermissionsHelper = new LocationPermissionsHelper(
this, locationManager, this);
locationPermissionsHelper.requestForLocationAccess(R.string.location_permission_title,
R.string.upload_map_location_access);
}
@Override
public void onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults) {
if (requestCode == Constants.RequestCodes.LOCATION
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
onLocationPermissionGranted();
} else {
onLocationPermissionDenied(getString(R.string.upload_map_location_access));
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@Override
protected void onResume() {
super.onResume();
mapView.onResume();
}
@Override
protected void onPause() {
super.onPause();
mapView.onPause();
}
@Override
public void onLocationPermissionDenied(String toastMessage) {
if (!ActivityCompat.shouldShowRequestPermissionRationale(this,
permission.ACCESS_FINE_LOCATION)) {
if (!locationPermissionsHelper.checkLocationPermission(this)) {
if (store.getBoolean("isPermissionDenied", false)) {
// means user has denied location permission twice or checked the "Don't show again"
locationPermissionsHelper.showAppSettingsDialog(this,
R.string.upload_map_location_access);
} else {
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
}
store.putBoolean("isPermissionDenied", true);
}
} else {
Toast.makeText(getBaseContext(), toastMessage, Toast.LENGTH_LONG).show();
}
}
@Override
public void onLocationPermissionGranted() {
if (moveToCurrentLocation || !(activity.equals("MediaActivity"))) {
if (locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
locationManager.requestLocationUpdatesFromProvider(
LocationManager.NETWORK_PROVIDER);
locationManager.requestLocationUpdatesFromProvider(LocationManager.GPS_PROVIDER);
addMarkerAtGPSLocation();
} else {
addMarkerAtGPSLocation();
locationPermissionsHelper.showLocationOffDialog(this,
R.string.ask_to_turn_location_on_text);
}
}
}
/**
* Adds a marker to the map at the most recent GPS location
* (which may be the current GPS location).
*/
private void addMarkerAtGPSLocation() {
fr.free.nrw.commons.location.LatLng currLocation = locationManager.getLastLocation();
if (currLocation != null) {
GeoPoint currLocationGeopoint = new GeoPoint(currLocation.getLatitude(),
currLocation.getLongitude());
addLocationMarker(currLocationGeopoint);
markerImage.setTranslationY(0);
}
}
private void addLocationMarker(GeoPoint geoPoint) {
if (moveToCurrentLocation) {
mapView.getOverlays().clear();
}
ScaleDiskOverlay diskOverlay =
new ScaleDiskOverlay(this,
geoPoint, 2000, GeoConstants.UnitOfMeasure.foot);
Paint circlePaint = new Paint();
circlePaint.setColor(Color.rgb(128, 128, 128));
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(2f);
diskOverlay.setCirclePaint2(circlePaint);
Paint diskPaint = new Paint();
diskPaint.setColor(Color.argb(40, 128, 128, 128));
diskPaint.setStyle(Paint.Style.FILL_AND_STROKE);
diskOverlay.setCirclePaint1(diskPaint);
diskOverlay.setDisplaySizeMin(900);
diskOverlay.setDisplaySizeMax(1700);
mapView.getOverlays().add(diskOverlay);
org.osmdroid.views.overlay.Marker startMarker = new org.osmdroid.views.overlay.Marker(
mapView);
startMarker.setPosition(geoPoint);
startMarker.setAnchor(org.osmdroid.views.overlay.Marker.ANCHOR_CENTER,
org.osmdroid.views.overlay.Marker.ANCHOR_BOTTOM);
startMarker.setIcon(
ContextCompat.getDrawable(this, R.drawable.current_location_marker));
startMarker.setTitle("Your Location");
startMarker.setTextLabelFontSize(24);
mapView.getOverlays().add(startMarker);
}
/**
* Saves the state of the activity
* @param outState Bundle
*/
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
if(cameraPosition!=null){
outState.putParcelable(CAMERA_POS, cameraPosition);
}
if(activity!=null){
outState.putString(ACTIVITY, activity);
}
if(media!=null){
outState.putParcelable("sMedia", media);
}
}
}

View file

@ -1,20 +0,0 @@
package fr.free.nrw.commons.LocationPicker;
/**
* Constants need for location picking
*/
public final class LocationPickerConstants {
public static final String ACTIVITY_KEY
= "location.picker.activity";
public static final String MAP_CAMERA_POSITION
= "location.picker.cameraPosition";
public static final String MEDIA
= "location.picker.media";
private LocationPickerConstants() {
}
}

View file

@ -1,63 +0,0 @@
package fr.free.nrw.commons.LocationPicker;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.MutableLiveData;
import fr.free.nrw.commons.CameraPosition;
import org.jetbrains.annotations.NotNull;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import timber.log.Timber;
/**
* Observes live camera position data
*/
public class LocationPickerViewModel extends AndroidViewModel implements Callback<CameraPosition> {
/**
* Wrapping CameraPosition with MutableLiveData
*/
private final MutableLiveData<CameraPosition> result = new MutableLiveData<>();
/**
* Constructor for this class
*
* @param application Application
*/
public LocationPickerViewModel(@NonNull final Application application) {
super(application);
}
/**
* Responses on camera position changing
*
* @param call Call<CameraPosition>
* @param response Response<CameraPosition>
*/
@Override
public void onResponse(final @NotNull Call<CameraPosition> call,
final Response<CameraPosition> response) {
if (response.body() == null) {
result.setValue(null);
return;
}
result.setValue(response.body());
}
@Override
public void onFailure(final @NotNull Call<CameraPosition> call, final @NotNull Throwable t) {
Timber.e(t);
}
/**
* Gets live CameraPosition
*
* @return MutableLiveData<CameraPosition>
*/
public MutableLiveData<CameraPosition> getResult() {
return result;
}
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons
import android.os.Parcelable import android.os.Parcelable
import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.wikidata.model.page.PageTitle import fr.free.nrw.commons.wikidata.model.page.PageTitle
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -124,6 +125,7 @@ class Media constructor(
* Gets the categories the file falls under. * Gets the categories the file falls under.
* @return file categories as an ArrayList of Strings * @return file categories as an ArrayList of Strings
*/ */
@IgnoredOnParcel
var addedCategories: List<String>? = null var addedCategories: List<String>? = null
// TODO added categories should be removed. It is added for a short fix. On category update, // TODO added categories should be removed. It is added for a short fix. On category update,
// categories should be re-fetched instead // categories should be re-fetched instead

View file

@ -50,6 +50,7 @@ public class WelcomeActivity extends BaseActivity {
copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater()); copyrightBinding = PopupForCopyrightBinding.inflate(getLayoutInflater());
final View contactPopupView = copyrightBinding.getRoot(); final View contactPopupView = copyrightBinding.getRoot();
dialogBuilder.setView(contactPopupView); dialogBuilder.setView(contactPopupView);
dialogBuilder.setCancelable(false);
dialog = dialogBuilder.create(); dialog = dialogBuilder.create();
dialog.show(); dialog.show();

View file

@ -3,7 +3,7 @@ package fr.free.nrw.commons.actions
import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.auth.csrf.CsrfTokenClient import fr.free.nrw.commons.auth.csrf.CsrfTokenClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.di.NetworkingModule.NAMED_COMMONS_CSRF import fr.free.nrw.commons.di.NetworkingModule.Companion.NAMED_COMMONS_CSRF
import io.reactivex.Observable import io.reactivex.Observable
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
@ -32,7 +32,7 @@ class ThanksClient
revisionId.toString(), // Rev revisionId.toString(), // Rev
null, // Log null, // Log
csrfTokenClient.getTokenBlocking(), // Token csrfTokenClient.getTokenBlocking(), // Token
CommonsApplication.getInstance().userAgent, // Source CommonsApplication.instance.userAgent, // Source
).map { mwThankPostResponse -> ).map { mwThankPostResponse ->
mwThankPostResponse.result?.success == 1 mwThankPostResponse.result?.success == 1
} }

View file

@ -1,44 +0,0 @@
package fr.free.nrw.commons.auth;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.BuildConfig;
import timber.log.Timber;
public class AccountUtil {
public static final String AUTH_TOKEN_TYPE = "CommonsAndroid";
public AccountUtil() {
}
/**
* @return Account|null
*/
@Nullable
public static Account account(Context context) {
try {
Account[] accounts = accountManager(context).getAccountsByType(BuildConfig.ACCOUNT_TYPE);
if (accounts.length > 0) {
return accounts[0];
}
} catch (SecurityException e) {
Timber.e(e);
}
return null;
}
@Nullable
public static String getUserName(Context context) {
Account account = account(context);
return account == null ? null : account.name;
}
private static AccountManager accountManager(Context context) {
return AccountManager.get(context);
}
}

View file

@ -0,0 +1,24 @@
package fr.free.nrw.commons.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
import timber.log.Timber
const val AUTH_TOKEN_TYPE: String = "CommonsAndroid"
fun getUserName(context: Context): String? {
return account(context)?.name
}
@VisibleForTesting
fun account(context: Context): Account? = try {
val accountManager = AccountManager.get(context)
val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
if (accounts.isNotEmpty()) accounts[0] else null
} catch (e: SecurityException) {
Timber.e(e)
null
}

View file

@ -1,456 +0,0 @@
package fr.free.nrw.commons.auth;
import android.accounts.AccountAuthenticatorActivity;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.TextView;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.NavUtils;
import androidx.core.content.ContextCompat;
import fr.free.nrw.commons.auth.login.LoginClient;
import fr.free.nrw.commons.auth.login.LoginResult;
import fr.free.nrw.commons.databinding.ActivityLoginBinding;
import fr.free.nrw.commons.utils.ActivityUtils;
import java.util.Locale;
import fr.free.nrw.commons.auth.login.LoginCallback;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.utils.SystemThemeUtils;
import fr.free.nrw.commons.utils.ViewUtil;
import io.reactivex.disposables.CompositeDisposable;
import timber.log.Timber;
import static android.view.KeyEvent.KEYCODE_ENTER;
import static android.view.View.VISIBLE;
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
import static fr.free.nrw.commons.CommonsApplication.loginMessageIntentKey;
import static fr.free.nrw.commons.CommonsApplication.loginUsernameIntentKey;
public class LoginActivity extends AccountAuthenticatorActivity {
@Inject
SessionManager sessionManager;
@Inject
@Named("default_preferences")
JsonKvStore applicationKvStore;
@Inject
LoginClient loginClient;
@Inject
SystemThemeUtils systemThemeUtils;
private ActivityLoginBinding binding;
ProgressDialog progressDialog;
private AppCompatDelegate delegate;
private LoginTextWatcher textWatcher = new LoginTextWatcher();
private CompositeDisposable compositeDisposable = new CompositeDisposable();
final String saveProgressDailog="ProgressDailog_state";
final String saveErrorMessage ="errorMessage";
final String saveUsername="username";
final String savePassword="password";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ApplicationlessInjection
.getInstance(this.getApplicationContext())
.getCommonsApplicationComponent()
.inject(this);
boolean isDarkTheme = systemThemeUtils.isDeviceInNightMode();
setTheme(isDarkTheme ? R.style.DarkAppTheme : R.style.LightAppTheme);
getDelegate().installViewFactory();
getDelegate().onCreate(savedInstanceState);
binding = ActivityLoginBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
String message = getIntent().getStringExtra(loginMessageIntentKey);
String username = getIntent().getStringExtra(loginUsernameIntentKey);
binding.loginUsername.addTextChangedListener(textWatcher);
binding.loginPassword.addTextChangedListener(textWatcher);
binding.loginTwoFactor.addTextChangedListener(textWatcher);
binding.skipLogin.setOnClickListener(view -> skipLogin());
binding.forgotPassword.setOnClickListener(view -> forgotPassword());
binding.aboutPrivacyPolicy.setOnClickListener(view -> onPrivacyPolicyClicked());
binding.signUpButton.setOnClickListener(view -> signUp());
binding.loginButton.setOnClickListener(view -> performLogin());
binding.loginPassword.setOnEditorActionListener(this::onEditorAction);
binding.loginPassword.setOnFocusChangeListener(this::onPasswordFocusChanged);
if (ConfigUtils.isBetaFlavour()) {
binding.loginCredentials.setText(getString(R.string.login_credential));
} else {
binding.loginCredentials.setVisibility(View.GONE);
}
if (message != null) {
showMessage(message, R.color.secondaryDarkColor);
}
if (username != null) {
binding.loginUsername.setText(username);
}
}
/**
* Hides the keyboard if the user's focus is not on the password (hasFocus is false).
* @param view The keyboard
* @param hasFocus Set to true if the keyboard has focus
*/
void onPasswordFocusChanged(View view, boolean hasFocus) {
if (!hasFocus) {
ViewUtil.hideKeyboard(view);
}
}
boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
if (binding.loginButton.isEnabled()) {
if (actionId == IME_ACTION_DONE) {
performLogin();
return true;
} else if ((keyEvent != null) && keyEvent.getKeyCode() == KEYCODE_ENTER) {
performLogin();
return true;
}
}
return false;
}
protected void skipLogin() {
new AlertDialog.Builder(this).setTitle(R.string.skip_login_title)
.setMessage(R.string.skip_login_message)
.setCancelable(false)
.setPositiveButton(R.string.yes, (dialog, which) -> {
dialog.cancel();
performSkipLogin();
})
.setNegativeButton(R.string.no, (dialog, which) -> dialog.cancel())
.show();
}
protected void forgotPassword() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL));
}
protected void onPrivacyPolicyClicked() {
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL));
}
protected void signUp() {
Intent intent = new Intent(this, SignupActivity.class);
startActivity(intent);
}
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
getDelegate().onPostCreate(savedInstanceState);
}
@Override
protected void onResume() {
super.onResume();
if (sessionManager.getCurrentAccount() != null
&& sessionManager.isUserLoggedIn()) {
applicationKvStore.putBoolean("login_skipped", false);
startMainActivity();
}
if (applicationKvStore.getBoolean("login_skipped", false)) {
performSkipLogin();
}
}
@Override
protected void onDestroy() {
compositeDisposable.clear();
try {
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
if (progressDialog != null && progressDialog.isShowing()) {
progressDialog.dismiss();
}
} catch (Exception e) {
e.printStackTrace();
}
binding.loginUsername.removeTextChangedListener(textWatcher);
binding.loginPassword.removeTextChangedListener(textWatcher);
binding.loginTwoFactor.removeTextChangedListener(textWatcher);
delegate.onDestroy();
if(null!=loginClient) {
loginClient.cancel();
}
binding = null;
super.onDestroy();
}
public void performLogin() {
Timber.d("Login to start!");
final String username = Objects.requireNonNull(binding.loginUsername.getText()).toString();
final String password = Objects.requireNonNull(binding.loginPassword.getText()).toString();
final String twoFactorCode = Objects.requireNonNull(binding.loginTwoFactor.getText()).toString();
showLoggingProgressBar();
loginClient.doLogin(username, password, twoFactorCode, Locale.getDefault().getLanguage(),
new LoginCallback() {
@Override
public void success(@NonNull LoginResult loginResult) {
runOnUiThread(()->{
Timber.d("Login Success");
hideProgress();
onLoginSuccess(loginResult);
});
}
@Override
public void twoFactorPrompt(@NonNull Throwable caught, @Nullable String token) {
runOnUiThread(()->{
Timber.d("Requesting 2FA prompt");
hideProgress();
askUserForTwoFactorAuth();
});
}
@Override
public void passwordResetPrompt(@Nullable String token) {
runOnUiThread(()->{
Timber.d("Showing password reset prompt");
hideProgress();
showPasswordResetPrompt();
});
}
@Override
public void error(@NonNull Throwable caught) {
runOnUiThread(()->{
Timber.e(caught);
hideProgress();
showMessageAndCancelDialog(caught.getLocalizedMessage());
});
}
});
}
private void hideProgress() {
progressDialog.dismiss();
}
private void showPasswordResetPrompt() {
showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword));
}
/**
* This function is called when user skips the login.
* It redirects the user to Explore Activity.
*/
private void performSkipLogin() {
applicationKvStore.putBoolean("login_skipped", true);
MainActivity.startYourself(this);
finish();
}
private void showLoggingProgressBar() {
progressDialog = new ProgressDialog(this);
progressDialog.setIndeterminate(true);
progressDialog.setTitle(getString(R.string.logging_in_title));
progressDialog.setMessage(getString(R.string.logging_in_message));
progressDialog.setCanceledOnTouchOutside(false);
progressDialog.show();
}
private void onLoginSuccess(LoginResult loginResult) {
compositeDisposable.clear();
sessionManager.setUserLoggedIn(true);
sessionManager.updateAccount(loginResult);
progressDialog.dismiss();
showSuccessAndDismissDialog();
startMainActivity();
}
@Override
protected void onStart() {
super.onStart();
delegate.onStart();
}
@Override
protected void onStop() {
super.onStop();
delegate.onStop();
}
@Override
protected void onPostResume() {
super.onPostResume();
getDelegate().onPostResume();
}
@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
getDelegate().setContentView(view, params);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
NavUtils.navigateUpFromSameTask(this);
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
@NonNull
public MenuInflater getMenuInflater() {
return getDelegate().getMenuInflater();
}
public void askUserForTwoFactorAuth() {
progressDialog.dismiss();
binding.twoFactorContainer.setVisibility(VISIBLE);
binding.loginTwoFactor.setVisibility(VISIBLE);
binding.loginTwoFactor.requestFocus();
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY);
showMessageAndCancelDialog(R.string.login_failed_2fa_needed);
}
public void showMessageAndCancelDialog(@StringRes int resId) {
showMessage(resId, R.color.secondaryDarkColor);
if (progressDialog != null) {
progressDialog.cancel();
}
}
public void showMessageAndCancelDialog(String error) {
showMessage(error, R.color.secondaryDarkColor);
if (progressDialog != null) {
progressDialog.cancel();
}
}
public void showSuccessAndDismissDialog() {
showMessage(R.string.login_success, R.color.primaryDarkColor);
progressDialog.dismiss();
}
public void startMainActivity() {
ActivityUtils.startActivityWithFlags(this, MainActivity.class, Intent.FLAG_ACTIVITY_SINGLE_TOP);
finish();
}
private void showMessage(@StringRes int resId, @ColorRes int colorResId) {
binding.errorMessage.setText(getString(resId));
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
binding.errorMessageContainer.setVisibility(VISIBLE);
}
private void showMessage(String message, @ColorRes int colorResId) {
binding.errorMessage.setText(message);
binding.errorMessage.setTextColor(ContextCompat.getColor(this, colorResId));
binding.errorMessageContainer.setVisibility(VISIBLE);
}
private AppCompatDelegate getDelegate() {
if (delegate == null) {
delegate = AppCompatDelegate.create(this, null);
}
return delegate;
}
private class LoginTextWatcher implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence charSequence, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable editable) {
boolean enabled = binding.loginUsername.getText().length() != 0 &&
binding.loginPassword.getText().length() != 0 &&
(BuildConfig.DEBUG || binding.loginTwoFactor.getText().length() != 0 ||
binding.loginTwoFactor.getVisibility() != VISIBLE);
binding.loginButton.setEnabled(enabled);
}
}
public static void startYourself(Context context) {
Intent intent = new Intent(context, LoginActivity.class);
context.startActivity(intent);
}
@Override
protected void onSaveInstanceState(Bundle outState) {
// if progressDialog is visible during the configuration change then store state as true else false so that
// we maintain visibility of progressDailog after configuration change
if(progressDialog!=null&&progressDialog.isShowing()) {
outState.putBoolean(saveProgressDailog,true);
} else {
outState.putBoolean(saveProgressDailog,false);
}
outState.putString(saveErrorMessage,binding.errorMessage.getText().toString()); //Save the errorMessage
outState.putString(saveUsername,getUsername()); // Save the username
outState.putString(savePassword,getPassword()); // Save the password
}
private String getUsername() {
return binding.loginUsername.getText().toString();
}
private String getPassword(){
return binding.loginPassword.getText().toString();
}
@Override
protected void onRestoreInstanceState(final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
binding.loginUsername.setText(savedInstanceState.getString(saveUsername));
binding.loginPassword.setText(savedInstanceState.getString(savePassword));
if(savedInstanceState.getBoolean(saveProgressDailog)) {
performLogin();
}
String errorMessage=savedInstanceState.getString(saveErrorMessage);
if(sessionManager.isUserLoggedIn()) {
showMessage(R.string.login_success, R.color.primaryDarkColor);
} else {
showMessage(errorMessage, R.color.secondaryDarkColor);
}
}
}

View file

@ -0,0 +1,404 @@
package fr.free.nrw.commons.auth
import android.accounts.AccountAuthenticatorActivity
import android.app.ProgressDialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NavUtils
import androidx.core.content.ContextCompat
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.auth.login.LoginCallback
import fr.free.nrw.commons.auth.login.LoginClient
import fr.free.nrw.commons.auth.login.LoginResult
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.databinding.ActivityLoginBinding
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.utils.AbstractTextWatcher
import fr.free.nrw.commons.utils.ActivityUtils.startActivityWithFlags
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.utils.SystemThemeUtils
import fr.free.nrw.commons.utils.ViewUtil.hideKeyboard
import io.reactivex.disposables.CompositeDisposable
import timber.log.Timber
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
class LoginActivity : AccountAuthenticatorActivity() {
@Inject
lateinit var sessionManager: SessionManager
@Inject
@field:Named("default_preferences")
lateinit var applicationKvStore: JsonKvStore
@Inject
lateinit var loginClient: LoginClient
@Inject
lateinit var systemThemeUtils: SystemThemeUtils
private var binding: ActivityLoginBinding? = null
private var progressDialog: ProgressDialog? = null
private val textWatcher = AbstractTextWatcher(::onTextChanged)
private val compositeDisposable = CompositeDisposable()
private val delegate: AppCompatDelegate by lazy {
AppCompatDelegate.create(this, null)
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationlessInjection
.getInstance(this.applicationContext)
.commonsApplicationComponent
.inject(this)
val isDarkTheme = systemThemeUtils.isDeviceInNightMode()
setTheme(if (isDarkTheme) R.style.DarkAppTheme else R.style.LightAppTheme)
delegate.installViewFactory()
delegate.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
with(binding!!) {
setContentView(root)
loginUsername.addTextChangedListener(textWatcher)
loginPassword.addTextChangedListener(textWatcher)
loginTwoFactor.addTextChangedListener(textWatcher)
skipLogin.setOnClickListener { skipLogin() }
forgotPassword.setOnClickListener { forgotPassword() }
aboutPrivacyPolicy.setOnClickListener { onPrivacyPolicyClicked() }
signUpButton.setOnClickListener { signUp() }
loginButton.setOnClickListener { performLogin() }
loginPassword.setOnEditorActionListener(::onEditorAction)
loginPassword.onFocusChangeListener =
View.OnFocusChangeListener(::onPasswordFocusChanged)
if (isBetaFlavour) {
loginCredentials.text = getString(R.string.login_credential)
} else {
loginCredentials.visibility = View.GONE
}
intent.getStringExtra(CommonsApplication.LOGIN_MESSAGE_INTENT_KEY)?.let {
showMessage(it, R.color.secondaryDarkColor)
}
intent.getStringExtra(CommonsApplication.LOGIN_USERNAME_INTENT_KEY)?.let {
loginUsername.setText(it)
}
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
delegate.onPostCreate(savedInstanceState)
}
override fun onResume() {
super.onResume()
if (sessionManager.currentAccount != null && sessionManager.isUserLoggedIn) {
applicationKvStore.putBoolean("login_skipped", false)
startMainActivity()
}
if (applicationKvStore.getBoolean("login_skipped", false)) {
performSkipLogin()
}
}
override fun onDestroy() {
compositeDisposable.clear()
try {
// To prevent leaked window when finish() is called, see http://stackoverflow.com/questions/32065854/activity-has-leaked-window-at-alertdialog-show-method
if (progressDialog?.isShowing == true) {
progressDialog!!.dismiss()
}
} catch (e: Exception) {
e.printStackTrace()
}
with(binding!!) {
loginUsername.removeTextChangedListener(textWatcher)
loginPassword.removeTextChangedListener(textWatcher)
loginTwoFactor.removeTextChangedListener(textWatcher)
}
delegate.onDestroy()
loginClient?.cancel()
binding = null
super.onDestroy()
}
override fun onStart() {
super.onStart()
delegate.onStart()
}
override fun onStop() {
super.onStop()
delegate.onStop()
}
override fun onPostResume() {
super.onPostResume()
delegate.onPostResume()
}
override fun setContentView(view: View, params: ViewGroup.LayoutParams) {
delegate.setContentView(view, params)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
NavUtils.navigateUpFromSameTask(this)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onSaveInstanceState(outState: Bundle) {
// if progressDialog is visible during the configuration change then store state as true else false so that
// we maintain visibility of progressDailog after configuration change
if (progressDialog != null && progressDialog!!.isShowing) {
outState.putBoolean(saveProgressDailog, true)
} else {
outState.putBoolean(saveProgressDailog, false)
}
outState.putString(
saveErrorMessage,
binding!!.errorMessage.text.toString()
) //Save the errorMessage
outState.putString(
saveUsername,
binding!!.loginUsername.text.toString()
) // Save the username
outState.putString(
savePassword,
binding!!.loginPassword.text.toString()
) // Save the password
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
binding!!.loginUsername.setText(savedInstanceState.getString(saveUsername))
binding!!.loginPassword.setText(savedInstanceState.getString(savePassword))
if (savedInstanceState.getBoolean(saveProgressDailog)) {
performLogin()
}
val errorMessage = savedInstanceState.getString(saveErrorMessage)
if (sessionManager.isUserLoggedIn) {
showMessage(R.string.login_success, R.color.primaryDarkColor)
} else {
showMessage(errorMessage, R.color.secondaryDarkColor)
}
}
/**
* Hides the keyboard if the user's focus is not on the password (hasFocus is false).
* @param view The keyboard
* @param hasFocus Set to true if the keyboard has focus
*/
private fun onPasswordFocusChanged(view: View, hasFocus: Boolean) {
if (!hasFocus) {
hideKeyboard(view)
}
}
private fun onEditorAction(textView: TextView, actionId: Int, keyEvent: KeyEvent?) =
if (binding!!.loginButton.isEnabled && isTriggerAction(actionId, keyEvent)) {
performLogin()
true
} else false
private fun isTriggerAction(actionId: Int, keyEvent: KeyEvent?) =
actionId == EditorInfo.IME_ACTION_DONE || keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
private fun skipLogin() {
AlertDialog.Builder(this)
.setTitle(R.string.skip_login_title)
.setMessage(R.string.skip_login_message)
.setCancelable(false)
.setPositiveButton(R.string.yes) { dialog: DialogInterface, which: Int ->
dialog.cancel()
performSkipLogin()
}
.setNegativeButton(R.string.no) { dialog: DialogInterface, which: Int ->
dialog.cancel()
}
.show()
}
private fun forgotPassword() =
Utils.handleWebUrl(this, Uri.parse(BuildConfig.FORGOT_PASSWORD_URL))
private fun onPrivacyPolicyClicked() =
Utils.handleWebUrl(this, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
private fun signUp() =
startActivity(Intent(this, SignupActivity::class.java))
@VisibleForTesting
fun performLogin() {
Timber.d("Login to start!")
val username = binding!!.loginUsername.text.toString()
val password = binding!!.loginPassword.text.toString()
val twoFactorCode = binding!!.loginTwoFactor.text.toString()
showLoggingProgressBar()
loginClient.doLogin(username,
password,
twoFactorCode,
Locale.getDefault().language,
object : LoginCallback {
override fun success(loginResult: LoginResult) = runOnUiThread {
Timber.d("Login Success")
progressDialog!!.dismiss()
onLoginSuccess(loginResult)
}
override fun twoFactorPrompt(caught: Throwable, token: String?) = runOnUiThread {
Timber.d("Requesting 2FA prompt")
progressDialog!!.dismiss()
askUserForTwoFactorAuth()
}
override fun passwordResetPrompt(token: String?) = runOnUiThread {
Timber.d("Showing password reset prompt")
progressDialog!!.dismiss()
showPasswordResetPrompt()
}
override fun error(caught: Throwable) = runOnUiThread {
Timber.e(caught)
progressDialog!!.dismiss()
showMessageAndCancelDialog(caught.localizedMessage ?: "")
}
}
)
}
private fun showPasswordResetPrompt() =
showMessageAndCancelDialog(getString(R.string.you_must_reset_your_passsword))
/**
* This function is called when user skips the login.
* It redirects the user to Explore Activity.
*/
private fun performSkipLogin() {
applicationKvStore.putBoolean("login_skipped", true)
MainActivity.startYourself(this)
finish()
}
private fun showLoggingProgressBar() {
progressDialog = ProgressDialog(this).apply {
isIndeterminate = true
setTitle(getString(R.string.logging_in_title))
setMessage(getString(R.string.logging_in_message))
setCancelable(false)
}
progressDialog!!.show()
}
private fun onLoginSuccess(loginResult: LoginResult) {
compositeDisposable.clear()
sessionManager.setUserLoggedIn(true)
sessionManager.updateAccount(loginResult)
progressDialog!!.dismiss()
showSuccessAndDismissDialog()
startMainActivity()
}
override fun getMenuInflater(): MenuInflater =
delegate.menuInflater
@VisibleForTesting
fun askUserForTwoFactorAuth() {
progressDialog!!.dismiss()
with(binding!!) {
twoFactorContainer.visibility = View.VISIBLE
loginTwoFactor.visibility = View.VISIBLE
loginTwoFactor.requestFocus()
}
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
showMessageAndCancelDialog(R.string.login_failed_2fa_needed)
}
@VisibleForTesting
fun showMessageAndCancelDialog(@StringRes resId: Int) {
showMessage(resId, R.color.secondaryDarkColor)
progressDialog?.cancel()
}
@VisibleForTesting
fun showMessageAndCancelDialog(error: String) {
showMessage(error, R.color.secondaryDarkColor)
progressDialog?.cancel()
}
@VisibleForTesting
fun showSuccessAndDismissDialog() {
showMessage(R.string.login_success, R.color.primaryDarkColor)
progressDialog!!.dismiss()
}
@VisibleForTesting
fun startMainActivity() {
startActivityWithFlags(this, MainActivity::class.java, Intent.FLAG_ACTIVITY_SINGLE_TOP)
finish()
}
private fun showMessage(@StringRes resId: Int, @ColorRes colorResId: Int) = with(binding!!) {
errorMessage.text = getString(resId)
errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
errorMessageContainer.visibility = View.VISIBLE
}
private fun showMessage(message: String?, @ColorRes colorResId: Int) = with(binding!!) {
errorMessage.text = message
errorMessage.setTextColor(ContextCompat.getColor(this@LoginActivity, colorResId))
errorMessageContainer.visibility = View.VISIBLE
}
private fun onTextChanged(text: String) {
val enabled =
binding!!.loginUsername.text!!.length != 0 && binding!!.loginPassword.text!!.length != 0 &&
(BuildConfig.DEBUG || binding!!.loginTwoFactor.text!!.length != 0 || binding!!.loginTwoFactor.visibility != View.VISIBLE)
binding!!.loginButton.isEnabled = enabled
}
companion object {
fun startYourself(context: Context) =
context.startActivity(Intent(context, LoginActivity::class.java))
const val saveProgressDailog: String = "ProgressDailog_state"
const val saveErrorMessage: String = "errorMessage"
const val saveUsername: String = "username"
const val savePassword: String = "password"
}
}

View file

@ -1,148 +0,0 @@
package fr.free.nrw.commons.auth;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.Context;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.auth.login.LoginResult;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import io.reactivex.Completable;
import io.reactivex.Observable;
/**
* Manage the current logged in user session.
*/
@Singleton
public class SessionManager {
private final Context context;
private Account currentAccount; // Unlike a savings account... ;-)
private JsonKvStore defaultKvStore;
@Inject
public SessionManager(Context context,
@Named("default_preferences") JsonKvStore defaultKvStore) {
this.context = context;
this.currentAccount = null;
this.defaultKvStore = defaultKvStore;
}
private boolean createAccount(@NonNull String userName, @NonNull String password) {
Account account = getCurrentAccount();
if (account == null || TextUtils.isEmpty(account.name) || !account.name.equals(userName)) {
removeAccount();
account = new Account(userName, BuildConfig.ACCOUNT_TYPE);
return accountManager().addAccountExplicitly(account, password, null);
}
return true;
}
private void removeAccount() {
Account account = getCurrentAccount();
if (account != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
accountManager().removeAccountExplicitly(account);
} else {
//noinspection deprecation
accountManager().removeAccount(account, null, null);
}
}
}
public void updateAccount(LoginResult result) {
boolean accountCreated = createAccount(result.getUserName(), result.getPassword());
if (accountCreated) {
setPassword(result.getPassword());
}
}
private void setPassword(@NonNull String password) {
Account account = getCurrentAccount();
if (account != null) {
accountManager().setPassword(account, password);
}
}
/**
* @return Account|null
*/
@Nullable
public Account getCurrentAccount() {
if (currentAccount == null) {
AccountManager accountManager = AccountManager.get(context);
Account[] allAccounts = accountManager.getAccountsByType(BuildConfig.ACCOUNT_TYPE);
if (allAccounts.length != 0) {
currentAccount = allAccounts[0];
}
}
return currentAccount;
}
public boolean doesAccountExist() {
return getCurrentAccount() != null;
}
@Nullable
public String getUserName() {
Account account = getCurrentAccount();
return account == null ? null : account.name;
}
@Nullable
public String getPassword() {
Account account = getCurrentAccount();
return account == null ? null : accountManager().getPassword(account);
}
private AccountManager accountManager() {
return AccountManager.get(context);
}
public boolean isUserLoggedIn() {
return defaultKvStore.getBoolean("isUserLoggedIn", false);
}
void setUserLoggedIn(boolean isLoggedIn) {
defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn);
}
public void forceLogin(Context context) {
if (context != null) {
LoginActivity.startYourself(context);
}
}
/**
* Returns a Completable that clears existing accounts from account manager
*/
public Completable logout() {
return Completable.fromObservable(
Observable.empty()
.doOnComplete(
() -> {
removeAccount();
currentAccount = null;
}
)
);
}
/**
* Return a corresponding boolean preference
*
* @param key
* @return
*/
public boolean getPreference(String key) {
return defaultKvStore.getBoolean(key);
}
}

View file

@ -0,0 +1,95 @@
package fr.free.nrw.commons.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Build
import android.text.TextUtils
import fr.free.nrw.commons.BuildConfig.ACCOUNT_TYPE
import fr.free.nrw.commons.auth.login.LoginResult
import fr.free.nrw.commons.kvstore.JsonKvStore
import io.reactivex.Completable
import io.reactivex.Observable
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* Manage the current logged in user session.
*/
@Singleton
class SessionManager @Inject constructor(
private val context: Context,
@param:Named("default_preferences") private val defaultKvStore: JsonKvStore
) {
private val accountManager: AccountManager get() = AccountManager.get(context)
private var _currentAccount: Account? = null // Unlike a savings account... ;-)
val currentAccount: Account? get() {
if (_currentAccount == null) {
val allAccounts = AccountManager.get(context).getAccountsByType(ACCOUNT_TYPE)
if (allAccounts.isNotEmpty()) {
_currentAccount = allAccounts[0]
}
}
return _currentAccount
}
val userName: String?
get() = currentAccount?.name
var password: String?
get() = currentAccount?.let { accountManager.getPassword(it) }
private set(value) {
currentAccount?.let { accountManager.setPassword(it, value) }
}
val isUserLoggedIn: Boolean
get() = defaultKvStore.getBoolean("isUserLoggedIn", false)
fun updateAccount(result: LoginResult) {
if (createAccount(result.userName!!, result.password!!)) {
password = result.password
}
}
fun doesAccountExist(): Boolean =
currentAccount != null
fun setUserLoggedIn(isLoggedIn: Boolean) =
defaultKvStore.putBoolean("isUserLoggedIn", isLoggedIn)
fun forceLogin(context: Context?) =
context?.let { LoginActivity.startYourself(it) }
fun getPreference(key: String): Boolean =
defaultKvStore.getBoolean(key)
fun logout(): Completable = Completable.fromObservable(
Observable.empty<Any>()
.doOnComplete {
removeAccount()
_currentAccount = null
}
)
private fun createAccount(userName: String, password: String): Boolean {
var account = currentAccount
if (account == null || TextUtils.isEmpty(account.name) || account.name != userName) {
removeAccount()
account = Account(userName, ACCOUNT_TYPE)
return accountManager.addAccountExplicitly(account, password, null)
}
return true
}
private fun removeAccount() {
currentAccount?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
accountManager.removeAccountExplicitly(it)
} else {
accountManager.removeAccount(it, null, null)
}
}
}
}

View file

@ -1,82 +0,0 @@
package fr.free.nrw.commons.auth;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.theme.BaseActivity;
import timber.log.Timber;
public class SignupActivity extends BaseActivity {
private WebView webView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Timber.d("Signup Activity started");
webView = new WebView(this);
setContentView(webView);
webView.setWebViewClient(new MyWebViewClient());
WebSettings webSettings = webView.getSettings();
/*Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
trust Wikimedia's site... right?*/
webSettings.setJavaScriptEnabled(true);
webView.loadUrl(BuildConfig.SIGNUP_LANDING_URL);
}
private class MyWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.equals(BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL)) {
//Signup success, so clear cookies, notify user, and load LoginActivity again
Timber.d("Overriding URL %s", url);
Toast toast = Toast.makeText(SignupActivity.this,
R.string.account_created, Toast.LENGTH_LONG);
toast.show();
// terminate on task completion.
finish();
return true;
} else {
//If user clicks any other links in the webview
Timber.d("Not overriding URL, URL is: %s", url);
return false;
}
}
}
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
/**
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
* https://issuetracker.google.com/issues/141132133
* App tries to put light/dark theme to webview and crashes in the process
* This code tries to prevent applying the theme when sdk is between api 21 to 25
* @param overrideConfiguration
*/
@Override
public void applyOverrideConfiguration(final Configuration overrideConfiguration) {
if (Build.VERSION.SDK_INT <= 25 &&
(getResources().getConfiguration().uiMode == getApplicationContext().getResources().getConfiguration().uiMode)) {
return;
}
super.applyOverrideConfiguration(overrideConfiguration);
}
}

View file

@ -0,0 +1,75 @@
package fr.free.nrw.commons.auth
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.R
import fr.free.nrw.commons.theme.BaseActivity
import timber.log.Timber
class SignupActivity : BaseActivity() {
private var webView: WebView? = null
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("Signup Activity started")
webView = WebView(this)
with(webView!!) {
setContentView(this)
webViewClient = MyWebViewClient()
// Needed to refresh Captcha. Might introduce XSS vulnerabilities, but we can
// trust Wikimedia's site... right?
settings.javaScriptEnabled = true
loadUrl(BuildConfig.SIGNUP_LANDING_URL)
}
}
override fun onBackPressed() {
if (webView!!.canGoBack()) {
webView!!.goBack()
} else {
super.onBackPressed()
}
}
/**
* Known bug in androidx.appcompat library version 1.1.0 being tracked here
* https://issuetracker.google.com/issues/141132133
* App tries to put light/dark theme to webview and crashes in the process
* This code tries to prevent applying the theme when sdk is between api 21 to 25
*/
override fun applyOverrideConfiguration(overrideConfiguration: Configuration) {
if (Build.VERSION.SDK_INT <= 25 &&
(resources.configuration.uiMode == applicationContext.resources.configuration.uiMode)
) return
super.applyOverrideConfiguration(overrideConfiguration)
}
private inner class MyWebViewClient : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean =
if (url == BuildConfig.SIGNUP_SUCCESS_REDIRECTION_URL) {
//Signup success, so clear cookies, notify user, and load LoginActivity again
Timber.d("Overriding URL %s", url)
Toast.makeText(
this@SignupActivity, R.string.account_created, Toast.LENGTH_LONG
).show()
// terminate on task completion.
finish()
true
} else {
//If user clicks any other links in the webview
Timber.d("Not overriding URL, URL is: %s", url)
false
}
}
}

View file

@ -1,141 +0,0 @@
package fr.free.nrw.commons.auth;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.BuildConfig;
import static fr.free.nrw.commons.auth.AccountUtil.AUTH_TOKEN_TYPE;
/**
* Handles WikiMedia commons account Authentication
*/
public class WikiAccountAuthenticator extends AbstractAccountAuthenticator {
private static final String[] SYNC_AUTHORITIES = {BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY};
@NonNull
private final Context context;
public WikiAccountAuthenticator(@NonNull Context context) {
super(context);
this.context = context;
}
/**
* Provides Bundle with edited Account Properties
*/
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
Bundle bundle = new Bundle();
bundle.putString("test", "editProperties");
return bundle;
}
@Override
public Bundle addAccount(@NonNull AccountAuthenticatorResponse response,
@NonNull String accountType, @Nullable String authTokenType,
@Nullable String[] requiredFeatures, @Nullable Bundle options)
throws NetworkErrorException {
// account type not supported returns bundle without loginActivity Intent, it just contains "test" key
if (!supportedAccountType(accountType)) {
Bundle bundle = new Bundle();
bundle.putString("test", "addAccount");
return bundle;
}
return addAccount(response);
}
@Override
public Bundle confirmCredentials(@NonNull AccountAuthenticatorResponse response,
@NonNull Account account, @Nullable Bundle options)
throws NetworkErrorException {
Bundle bundle = new Bundle();
bundle.putString("test", "confirmCredentials");
return bundle;
}
@Override
public Bundle getAuthToken(@NonNull AccountAuthenticatorResponse response,
@NonNull Account account, @NonNull String authTokenType,
@Nullable Bundle options)
throws NetworkErrorException {
Bundle bundle = new Bundle();
bundle.putString("test", "getAuthToken");
return bundle;
}
@Nullable
@Override
public String getAuthTokenLabel(@NonNull String authTokenType) {
return supportedAccountType(authTokenType) ? AUTH_TOKEN_TYPE : null;
}
@Nullable
@Override
public Bundle updateCredentials(@NonNull AccountAuthenticatorResponse response,
@NonNull Account account, @Nullable String authTokenType,
@Nullable Bundle options)
throws NetworkErrorException {
Bundle bundle = new Bundle();
bundle.putString("test", "updateCredentials");
return bundle;
}
@Nullable
@Override
public Bundle hasFeatures(@NonNull AccountAuthenticatorResponse response,
@NonNull Account account, @NonNull String[] features)
throws NetworkErrorException {
Bundle bundle = new Bundle();
bundle.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false);
return bundle;
}
private boolean supportedAccountType(@Nullable String type) {
return BuildConfig.ACCOUNT_TYPE.equals(type);
}
/**
* Provides a bundle containing a Parcel
* the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
*/
private Bundle addAccount(AccountAuthenticatorResponse response) {
Intent intent = new Intent(context, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response,
Account account) throws NetworkErrorException {
Bundle result = super.getAccountRemovalAllowed(response, account);
if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
&& !result.containsKey(AccountManager.KEY_INTENT)) {
boolean allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT);
if (allowed) {
for (String auth : SYNC_AUTHORITIES) {
ContentResolver.cancelSync(account, auth);
}
}
}
return result;
}
}

View file

@ -0,0 +1,108 @@
package fr.free.nrw.commons.auth
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.accounts.NetworkErrorException
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.os.bundleOf
import fr.free.nrw.commons.BuildConfig
private val SYNC_AUTHORITIES = arrayOf(
BuildConfig.CONTRIBUTION_AUTHORITY, BuildConfig.MODIFICATION_AUTHORITY
)
/**
* Handles WikiMedia commons account Authentication
*/
class WikiAccountAuthenticator(
private val context: Context
) : AbstractAccountAuthenticator(context) {
/**
* Provides Bundle with edited Account Properties
*/
override fun editProperties(
response: AccountAuthenticatorResponse,
accountType: String
) = bundleOf("test" to "editProperties")
// account type not supported returns bundle without loginActivity Intent, it just contains "test" key
@Throws(NetworkErrorException::class)
override fun addAccount(
response: AccountAuthenticatorResponse,
accountType: String,
authTokenType: String?,
requiredFeatures: Array<String>?,
options: Bundle?
) = if (BuildConfig.ACCOUNT_TYPE == accountType) {
addAccount(response)
} else {
bundleOf("test" to "addAccount")
}
@Throws(NetworkErrorException::class)
override fun confirmCredentials(
response: AccountAuthenticatorResponse, account: Account, options: Bundle?
) = bundleOf("test" to "confirmCredentials")
@Throws(NetworkErrorException::class)
override fun getAuthToken(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String,
options: Bundle?
) = bundleOf("test" to "getAuthToken")
override fun getAuthTokenLabel(authTokenType: String) =
if (BuildConfig.ACCOUNT_TYPE == authTokenType) AUTH_TOKEN_TYPE else null
@Throws(NetworkErrorException::class)
override fun updateCredentials(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String?,
options: Bundle?
) = bundleOf("test" to "updateCredentials")
@Throws(NetworkErrorException::class)
override fun hasFeatures(
response: AccountAuthenticatorResponse,
account: Account, features: Array<String>
) = bundleOf(AccountManager.KEY_BOOLEAN_RESULT to false)
/**
* Provides a bundle containing a Parcel
* the Parcel packs an Intent with LoginActivity and Authenticator response (requires valid account type)
*/
private fun addAccount(response: AccountAuthenticatorResponse): Bundle {
val intent = Intent(context, LoginActivity::class.java)
.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
return bundleOf(AccountManager.KEY_INTENT to intent)
}
@Throws(NetworkErrorException::class)
override fun getAccountRemovalAllowed(
response: AccountAuthenticatorResponse?,
account: Account?
): Bundle {
val result = super.getAccountRemovalAllowed(response, account)
if (result.containsKey(AccountManager.KEY_BOOLEAN_RESULT)
&& !result.containsKey(AccountManager.KEY_INTENT)
) {
val allowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)
if (allowed) {
for (auth in SYNC_AUTHORITIES) {
ContentResolver.cancelSync(account, auth)
}
}
}
return result
}
}

View file

@ -1,31 +0,0 @@
package fr.free.nrw.commons.auth;
import android.accounts.AbstractAccountAuthenticator;
import android.content.Intent;
import android.os.IBinder;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.di.CommonsDaggerService;
/**
* Handles the Auth service of the App, see AndroidManifests for details
* (Uses Dagger 2 as injector)
*/
public class WikiAccountAuthenticatorService extends CommonsDaggerService {
@Nullable
private AbstractAccountAuthenticator authenticator;
@Override
public void onCreate() {
super.onCreate();
authenticator = new WikiAccountAuthenticator(this);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return authenticator == null ? null : authenticator.getIBinder();
}
}

View file

@ -0,0 +1,22 @@
package fr.free.nrw.commons.auth
import android.accounts.AbstractAccountAuthenticator
import android.content.Intent
import android.os.IBinder
import fr.free.nrw.commons.di.CommonsDaggerService
/**
* Handles the Auth service of the App, see AndroidManifests for details
* (Uses Dagger 2 as injector)
*/
class WikiAccountAuthenticatorService : CommonsDaggerService() {
private var authenticator: AbstractAccountAuthenticator? = null
override fun onCreate() {
super.onCreate()
authenticator = WikiAccountAuthenticator(this)
}
override fun onBind(intent: Intent): IBinder? =
authenticator?.iBinder
}

View file

@ -237,7 +237,7 @@ class LoginClient(
.subscribe({ response: MwQueryResponse? -> .subscribe({ response: MwQueryResponse? ->
loginResult.userId = response?.query()?.userInfo()?.id() ?: 0 loginResult.userId = response?.query()?.userInfo()?.id() ?: 0
loginResult.groups = loginResult.groups =
response?.query()?.getUserResponse(userName)?.groups ?: emptySet() response?.query()?.getUserResponse(userName)?.getGroups() ?: emptySet()
cb.success(loginResult) cb.success(loginResult)
}, { caught: Throwable -> }, { caught: Throwable ->
Timber.e(caught, "Login succeeded but getting group information failed. ") Timber.e(caught, "Login succeeded but getting group information failed. ")

View file

@ -1,32 +0,0 @@
package fr.free.nrw.commons.bookmarks;
import androidx.fragment.app.Fragment;
/**
* Data class for handling a bookmark fragment and it title
*/
public class BookmarkPages {
private Fragment page;
private String title;
BookmarkPages(Fragment fragment, String title) {
this.title = title;
this.page = fragment;
}
/**
* Return the fragment
* @return fragment object
*/
public Fragment getPage() {
return page;
}
/**
* Return the fragment title
* @return title
*/
public String getTitle() {
return title;
}
}

View file

@ -0,0 +1,8 @@
package fr.free.nrw.commons.bookmarks
import androidx.fragment.app.Fragment
data class BookmarkPages (
val page: Fragment? = null,
val title: String? = null
)

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.bookmarks.items; package fr.free.nrw.commons.bookmarks.items;
import android.annotation.SuppressLint;
import android.content.ContentProviderClient; import android.content.ContentProviderClient;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
@ -134,6 +135,7 @@ public class BookmarkItemsDao {
* @param cursor : Object for storing database data * @param cursor : Object for storing database data
* @return DepictedItem * @return DepictedItem
*/ */
@SuppressLint("Range")
DepictedItem fromCursor(final Cursor cursor) { DepictedItem fromCursor(final Cursor cursor) {
final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)); final String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME));
final String description final String description

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.bookmarks.locations; package fr.free.nrw.commons.bookmarks.locations;
import android.annotation.SuppressLint;
import android.content.ContentProviderClient; import android.content.ContentProviderClient;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
@ -146,6 +147,7 @@ public class BookmarkLocationsDao {
return false; return false;
} }
@SuppressLint("Range")
@NonNull @NonNull
Place fromCursor(final Cursor cursor) { Place fromCursor(final Cursor cursor) {
final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)), final LatLng location = new LatLng(cursor.getDouble(cursor.getColumnIndex(Table.COLUMN_LAT)),

View file

@ -9,6 +9,7 @@ import android.view.ViewGroup;
import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts; import androidx.activity.result.contract.ActivityResultContracts;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
@ -33,6 +34,23 @@ public class BookmarkLocationsFragment extends DaggerFragment {
@Inject BookmarkLocationsDao bookmarkLocationDao; @Inject BookmarkLocationsDao bookmarkLocationDao;
@Inject CommonPlaceClickActions commonPlaceClickActions; @Inject CommonPlaceClickActions commonPlaceClickActions;
private PlaceAdapter adapter; private PlaceAdapter adapter;
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
contributionController.handleActivityResultWithCallback(requireActivity(), callbacks -> {
contributionController.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() { private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<Map<String, Boolean>>() {
@Override @Override
public void onActivityResult(Map<String, Boolean> result) { public void onActivityResult(Map<String, Boolean> result) {
@ -45,7 +63,7 @@ public class BookmarkLocationsFragment extends DaggerFragment {
contributionController.locationPermissionCallback.onLocationPermissionGranted(); contributionController.locationPermissionCallback.onLocationPermissionGranted();
} else { } else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher); contributionController.handleShowRationaleFlowCameraLocation(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
} else { } else {
contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied)); contributionController.locationPermissionCallback.onLocationPermissionDenied(getActivity().getString(R.string.in_app_camera_location_permission_denied));
} }
@ -83,7 +101,9 @@ public class BookmarkLocationsFragment extends DaggerFragment {
return Unit.INSTANCE; return Unit.INSTANCE;
}, },
commonPlaceClickActions, commonPlaceClickActions,
inAppCameraLocationPermissionLauncher inAppCameraLocationPermissionLauncher,
galleryPickLauncherForResult,
cameraPickLauncherForResult
); );
binding.listView.setAdapter(adapter); binding.listView.setAdapter(adapter);
} }
@ -109,11 +129,6 @@ public class BookmarkLocationsFragment extends DaggerFragment {
} }
} }
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
contributionController.handleActivityResult(getActivity(), requestCode, resultCode, data);
}
@Override @Override
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.bookmarks.pictures; package fr.free.nrw.commons.bookmarks.pictures;
import android.annotation.SuppressLint;
import android.content.ContentProviderClient; import android.content.ContentProviderClient;
import android.content.ContentValues; import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
@ -150,6 +151,7 @@ public class BookmarkPicturesDao {
return false; return false;
} }
@SuppressLint("Range")
@NonNull @NonNull
Bookmark fromCursor(Cursor cursor) { Bookmark fromCursor(Cursor cursor) {
String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME)); String fileName = cursor.getString(cursor.getColumnIndex(Table.COLUMN_MEDIA_NAME));

View file

@ -1,118 +0,0 @@
package fr.free.nrw.commons.campaigns;
import android.content.Context;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import fr.free.nrw.commons.campaigns.models.Campaign;
import fr.free.nrw.commons.databinding.LayoutCampaginBinding;
import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.utils.DateUtil;
import java.text.ParseException;
import java.util.Date;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import fr.free.nrw.commons.utils.SwipableCardView;
import fr.free.nrw.commons.utils.ViewUtil;
/**
* A view which represents a single campaign
*/
public class CampaignView extends SwipableCardView {
Campaign campaign;
private LayoutCampaginBinding binding;
private ViewHolder viewHolder;
public static final String CAMPAIGNS_DEFAULT_PREFERENCE = "displayCampaignsCardView";
public static final String WLM_CARD_PREFERENCE = "displayWLMCardView";
private String campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE;
public CampaignView(@NonNull Context context) {
super(context);
init();
}
public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public CampaignView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public void setCampaign(final Campaign campaign) {
this.campaign = campaign;
if (campaign != null) {
if (campaign.isWLMCampaign()) {
campaignPreference = WLM_CARD_PREFERENCE;
}
setVisibility(View.VISIBLE);
viewHolder.init();
} else {
this.setVisibility(View.GONE);
}
}
@Override public boolean onSwipe(final View view) {
view.setVisibility(View.GONE);
((BaseActivity) getContext()).defaultKvStore
.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false);
ViewUtil.showLongToast(getContext(),
getResources().getString(R.string.nearby_campaign_dismiss_message));
return true;
}
private void init() {
binding = LayoutCampaginBinding.inflate(LayoutInflater.from(getContext()), this, true);
viewHolder = new ViewHolder();
setOnClickListener(view -> {
if (campaign != null) {
if (campaign.isWLMCampaign()) {
((MainActivity)(getContext())).showNearby();
} else {
Utils.handleWebUrl(getContext(), Uri.parse(campaign.getLink()));
}
}
});
}
public class ViewHolder {
public void init() {
if (campaign != null) {
binding.ivCampaign.setImageDrawable(
getResources().getDrawable(R.drawable.ic_campaign));
binding.tvTitle.setText(campaign.getTitle());
binding.tvDescription.setText(campaign.getDescription());
try {
if (campaign.isWLMCampaign()) {
binding.tvDates.setText(
String.format("%1s - %2s", campaign.getStartDate(),
campaign.getEndDate()));
} else {
final Date startDate = CommonsDateUtil.getIso8601DateFormatShort()
.parse(campaign.getStartDate());
final Date endDate = CommonsDateUtil.getIso8601DateFormatShort()
.parse(campaign.getEndDate());
binding.tvDates.setText(String.format("%1s - %2s", DateUtil.getExtraShortDateString(startDate),
DateUtil.getExtraShortDateString(endDate)));
}
} catch (final ParseException e) {
e.printStackTrace();
}
}
}
}
}

View file

@ -0,0 +1,121 @@
package fr.free.nrw.commons.campaigns
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import androidx.core.content.ContextCompat
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.campaigns.models.Campaign
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.databinding.LayoutCampaginBinding
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
import fr.free.nrw.commons.utils.DateUtil.getExtraShortDateString
import fr.free.nrw.commons.utils.SwipableCardView
import fr.free.nrw.commons.utils.ViewUtil.showLongToast
import timber.log.Timber
import java.text.ParseException
/**
* A view which represents a single campaign
*/
class CampaignView : SwipableCardView {
private var campaign: Campaign? = null
private var binding: LayoutCampaginBinding? = null
private var viewHolder: ViewHolder? = null
private var campaignPreference = CAMPAIGNS_DEFAULT_PREFERENCE
constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context, attrs, defStyleAttr) {
init()
}
fun setCampaign(campaign: Campaign?) {
this.campaign = campaign
if (campaign != null) {
if (campaign.isWLMCampaign) {
campaignPreference = WLM_CARD_PREFERENCE
}
visibility = VISIBLE
viewHolder!!.init()
} else {
visibility = GONE
}
}
override fun onSwipe(view: View): Boolean {
view.visibility = GONE
(context as BaseActivity).defaultKvStore.putBoolean(CAMPAIGNS_DEFAULT_PREFERENCE, false)
showLongToast(
context,
resources.getString(R.string.nearby_campaign_dismiss_message)
)
return true
}
private fun init() {
binding = LayoutCampaginBinding.inflate(
LayoutInflater.from(context), this, true
)
viewHolder = ViewHolder()
setOnClickListener {
campaign?.let {
if (it.isWLMCampaign) {
((context) as MainActivity).showNearby()
} else {
Utils.handleWebUrl(context, Uri.parse(it.link))
}
}
}
}
inner class ViewHolder {
fun init() {
if (campaign != null) {
binding!!.ivCampaign.setImageDrawable(
ContextCompat.getDrawable(binding!!.root.context, R.drawable.ic_campaign)
)
binding!!.tvTitle.text = campaign!!.title
binding!!.tvDescription.text = campaign!!.description
try {
if (campaign!!.isWLMCampaign) {
binding!!.tvDates.text = String.format(
"%1s - %2s", campaign!!.startDate,
campaign!!.endDate
)
} else {
val startDate = getIso8601DateFormatShort().parse(
campaign?.startDate
)
val endDate = getIso8601DateFormatShort().parse(
campaign?.endDate
)
binding!!.tvDates.text = String.format(
"%1s - %2s", getExtraShortDateString(
startDate!!
), getExtraShortDateString(endDate!!)
)
}
} catch (e: ParseException) {
Timber.e(e)
}
}
}
}
companion object {
const val CAMPAIGNS_DEFAULT_PREFERENCE: String = "displayCampaignsCardView"
const val WLM_CARD_PREFERENCE: String = "displayWLMCardView"
}
}

View file

@ -1,123 +0,0 @@
package fr.free.nrw.commons.campaigns;
import android.annotation.SuppressLint;
import fr.free.nrw.commons.campaigns.models.Campaign;
import java.text.ParseException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import fr.free.nrw.commons.BasePresenter;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.utils.CommonsDateUtil;
import io.reactivex.Scheduler;
import io.reactivex.Single;
import io.reactivex.SingleObserver;
import io.reactivex.disposables.Disposable;
import timber.log.Timber;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.di.CommonsApplicationModule.MAIN_THREAD;
/**
* The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
* success and error
*/
@Singleton
public class CampaignsPresenter implements BasePresenter<ICampaignsView> {
private final OkHttpJsonApiClient okHttpJsonApiClient;
private final Scheduler mainThreadScheduler;
private final Scheduler ioScheduler;
private ICampaignsView view;
private Disposable disposable;
private Campaign campaign;
@Inject
public CampaignsPresenter(OkHttpJsonApiClient okHttpJsonApiClient, @Named(IO_THREAD)Scheduler ioScheduler, @Named(MAIN_THREAD)Scheduler mainThreadScheduler) {
this.okHttpJsonApiClient = okHttpJsonApiClient;
this.mainThreadScheduler=mainThreadScheduler;
this.ioScheduler=ioScheduler;
}
@Override
public void onAttachView(ICampaignsView view) {
this.view = view;
}
@Override public void onDetachView() {
this.view = null;
if (disposable != null) {
disposable.dispose();
}
}
/**
* make the api call to fetch the campaigns
*/
@SuppressLint("CheckResult")
public void getCampaigns() {
if (view != null && okHttpJsonApiClient != null) {
//If we already have a campaign, lets not make another call
if (this.campaign != null) {
view.showCampaigns(campaign);
return;
}
Single<CampaignResponseDTO> campaigns = okHttpJsonApiClient.getCampaigns();
campaigns.observeOn(mainThreadScheduler)
.subscribeOn(ioScheduler)
.subscribeWith(new SingleObserver<CampaignResponseDTO>() {
@Override public void onSubscribe(Disposable d) {
disposable = d;
}
@Override public void onSuccess(CampaignResponseDTO campaignResponseDTO) {
List<Campaign> campaigns = campaignResponseDTO.getCampaigns();
if (campaigns == null || campaigns.isEmpty()) {
Timber.e("The campaigns list is empty");
view.showCampaigns(null);
return;
}
Collections.sort(campaigns, (campaign, t1) -> {
Date date1, date2;
try {
date1 = CommonsDateUtil.getIso8601DateFormatShort().parse(campaign.getStartDate());
date2 = CommonsDateUtil.getIso8601DateFormatShort().parse(t1.getStartDate());
} catch (ParseException e) {
e.printStackTrace();
return -1;
}
return date1.compareTo(date2);
});
Date campaignEndDate, campaignStartDate;
Date currentDate = new Date();
try {
for (Campaign aCampaign : campaigns) {
campaignEndDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getEndDate());
campaignStartDate = CommonsDateUtil.getIso8601DateFormatShort().parse(aCampaign.getStartDate());
if (campaignEndDate.compareTo(currentDate) >= 0
&& campaignStartDate.compareTo(currentDate) <= 0) {
campaign = aCampaign;
break;
}
}
} catch (ParseException e) {
e.printStackTrace();
}
view.showCampaigns(campaign);
}
@Override public void onError(Throwable e) {
Timber.e(e, "could not fetch campaigns");
}
});
}
}
}

View file

@ -0,0 +1,106 @@
package fr.free.nrw.commons.campaigns
import android.annotation.SuppressLint
import fr.free.nrw.commons.BasePresenter
import fr.free.nrw.commons.campaigns.models.Campaign
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.MAIN_THREAD
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import fr.free.nrw.commons.utils.CommonsDateUtil.getIso8601DateFormatShort
import io.reactivex.Scheduler
import io.reactivex.disposables.Disposable
import timber.log.Timber
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* The presenter for the campaigns view, fetches the campaigns from the api and informs the view on
* success and error
*/
@Singleton
class CampaignsPresenter @Inject constructor(
private val okHttpJsonApiClient: OkHttpJsonApiClient?,
@param:Named(IO_THREAD) private val ioScheduler: Scheduler,
@param:Named(MAIN_THREAD) private val mainThreadScheduler: Scheduler
) : BasePresenter<ICampaignsView?> {
private var view: ICampaignsView? = null
private var disposable: Disposable? = null
private var campaign: Campaign? = null
override fun onAttachView(view: ICampaignsView) {
this.view = view
}
override fun onDetachView() {
view = null
disposable?.dispose()
}
/**
* make the api call to fetch the campaigns
*/
@SuppressLint("CheckResult")
fun getCampaigns() {
if (view != null && okHttpJsonApiClient != null) {
//If we already have a campaign, lets not make another call
if (campaign != null) {
view!!.showCampaigns(campaign)
return
}
okHttpJsonApiClient.getCampaigns()
.observeOn(mainThreadScheduler)
.subscribeOn(ioScheduler)
.doOnSubscribe { disposable = it }
.subscribe({ campaignResponseDTO ->
val campaigns = campaignResponseDTO?.campaigns?.toMutableList()
if (campaigns.isNullOrEmpty()) {
Timber.e("The campaigns list is empty")
view!!.showCampaigns(null)
} else {
sortCampaignsByStartDate(campaigns)
campaign = findActiveCampaign(campaigns)
view!!.showCampaigns(campaign)
}
}, {
Timber.e(it, "could not fetch campaigns")
})
}
}
private fun sortCampaignsByStartDate(campaigns: MutableList<Campaign>) {
val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
campaigns.sortWith(Comparator { campaign: Campaign, other: Campaign ->
val date1: Date?
val date2: Date?
try {
date1 = campaign.startDate?.let { dateFormat.parse(it) }
date2 = other.startDate?.let { dateFormat.parse(it) }
} catch (e: ParseException) {
Timber.e(e)
return@Comparator -1
}
if (date1 != null && date2 != null) date1.compareTo(date2) else -1
})
}
private fun findActiveCampaign(campaigns: List<Campaign>) : Campaign? {
val dateFormat: SimpleDateFormat = getIso8601DateFormatShort()
val currentDate = Date()
return try {
campaigns.firstOrNull {
val campaignStartDate = it.startDate?.let { s -> dateFormat.parse(s) }
val campaignEndDate = it.endDate?.let { s -> dateFormat.parse(s) }
campaignStartDate != null && campaignEndDate != null &&
campaignEndDate >= currentDate && campaignStartDate <= currentDate
}
} catch (e: ParseException) {
Timber.e(e, "could not find active campaign")
null
}
}
}

View file

@ -1,11 +0,0 @@
package fr.free.nrw.commons.campaigns;
import fr.free.nrw.commons.MvpView;
import fr.free.nrw.commons.campaigns.models.Campaign;
/**
* Interface which defines the view contracts of the campaign view
*/
public interface ICampaignsView extends MvpView {
void showCampaigns(Campaign campaign);
}

View file

@ -0,0 +1,11 @@
package fr.free.nrw.commons.campaigns
import fr.free.nrw.commons.MvpView
import fr.free.nrw.commons.campaigns.models.Campaign
/**
* Interface which defines the view contracts of the campaign view
*/
interface ICampaignsView : MvpView {
fun showCampaigns(campaign: Campaign?)
}

View file

@ -78,7 +78,13 @@ class CategoriesModel
// Newly used category... // Newly used category...
if (category == null) { if (category == null) {
category = Category(null, item.name, item.description, item.thumbnail, Date(), 0) category = Category(
null, item.name,
item.description,
item.thumbnail,
Date(),
0
)
} }
category.incTimesUsed() category.incTimesUsed()
categoryDao.save(category) categoryDao.save(category)

View file

@ -1,115 +0,0 @@
package fr.free.nrw.commons.category;
import android.net.Uri;
import java.util.Date;
/**
* Represents a category
*/
public class Category {
private Uri contentUri;
private String name;
private String description;
private String thumbnail;
private Date lastUsed;
private int timesUsed;
public Category() {
}
public Category(Uri contentUri, String name, String description, String thumbnail, Date lastUsed, int timesUsed) {
this.contentUri = contentUri;
this.name = name;
this.description = description;
this.thumbnail = thumbnail;
this.lastUsed = lastUsed;
this.timesUsed = timesUsed;
}
/**
* Gets name
*
* @return name
*/
public String getName() {
return name;
}
/**
* Modifies name
*
* @param name Category name
*/
public void setName(String name) {
this.name = name;
}
/**
* Gets last used date
*
* @return Last used date
*/
public Date getLastUsed() {
// warning: Date objects are mutable.
return (Date)lastUsed.clone();
}
/**
* Generates new last used date
*/
private void touch() {
lastUsed = new Date();
}
/**
* Gets no. of times the category is used
*
* @return no. of times used
*/
public int getTimesUsed() {
return timesUsed;
}
/**
* Increments timesUsed by 1 and sets last used date as now.
*/
public void incTimesUsed() {
timesUsed++;
touch();
}
/**
* Gets the content URI for this category
*
* @return content URI
*/
public Uri getContentUri() {
return contentUri;
}
/**
* Modifies the content URI - marking this category as already saved in the database
*
* @param contentUri the content URI
*/
public void setContentUri(Uri contentUri) {
this.contentUri = contentUri;
}
public String getDescription() {
return description;
}
public String getThumbnail() {
return thumbnail;
}
public void setDescription(final String description) {
this.description = description;
}
public void setThumbnail(final String thumbnail) {
this.thumbnail = thumbnail;
}
}

View file

@ -0,0 +1,17 @@
package fr.free.nrw.commons.category
import android.net.Uri
import java.util.Date
data class Category(
var contentUri: Uri? = null,
val name: String? = null,
val description: String? = null,
val thumbnail: String? = null,
val lastUsed: Date? = null,
var timesUsed: Int = 0
) {
fun incTimesUsed() {
timesUsed++
}
}

View file

@ -1,5 +0,0 @@
package fr.free.nrw.commons.category;
public interface CategoryClickedListener {
void categoryClicked(CategoryItem item);
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.category
interface CategoryClickedListener {
fun categoryClicked(item: CategoryItem)
}

View file

@ -124,7 +124,9 @@ class CategoryClient
}.map { }.map {
it it
.filter { page -> .filter { page ->
page.categoryInfo() == null || !page.categoryInfo().isHidden // Null check is not redundant because some values could be null
// for mocks when running unit tests
page.categoryInfo()?.isHidden != true
}.map { }.map {
CategoryItem( CategoryItem(
it.title().replace(CATEGORY_PREFIX, ""), it.title().replace(CATEGORY_PREFIX, ""),

View file

@ -1,169 +0,0 @@
package fr.free.nrw.commons.category;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import javax.inject.Inject;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.di.CommonsDaggerContentProvider;
import timber.log.Timber;
import static android.content.UriMatcher.NO_MATCH;
import static fr.free.nrw.commons.category.CategoryDao.Table.ALL_FIELDS;
import static fr.free.nrw.commons.category.CategoryDao.Table.COLUMN_ID;
import static fr.free.nrw.commons.category.CategoryDao.Table.TABLE_NAME;
public class CategoryContentProvider extends CommonsDaggerContentProvider {
// For URI matcher
private static final int CATEGORIES = 1;
private static final int CATEGORIES_ID = 2;
private static final String BASE_PATH = "categories";
public static final Uri BASE_URI = Uri.parse("content://" + BuildConfig.CATEGORY_AUTHORITY + "/" + BASE_PATH);
private static final UriMatcher uriMatcher = new UriMatcher(NO_MATCH);
static {
uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES);
uriMatcher.addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID);
}
public static Uri uriForId(int id) {
return Uri.parse(BASE_URI.toString() + "/" + id);
}
@Inject DBOpenHelper dbOpenHelper;
@SuppressWarnings("ConstantConditions")
@Override
public Cursor query(@NonNull Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder();
queryBuilder.setTables(TABLE_NAME);
int uriType = uriMatcher.match(uri);
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor;
switch (uriType) {
case CATEGORIES:
cursor = queryBuilder.query(db, projection, selection, selectionArgs,
null, null, sortOrder);
break;
case CATEGORIES_ID:
cursor = queryBuilder.query(db,
ALL_FIELDS,
"_id = ?",
new String[]{uri.getLastPathSegment()},
null,
null,
sortOrder
);
break;
default:
throw new IllegalArgumentException("Unknown URI" + uri);
}
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Override
public String getType(@NonNull Uri uri) {
return null;
}
@SuppressWarnings("ConstantConditions")
@Override
public Uri insert(@NonNull Uri uri, ContentValues contentValues) {
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
long id;
switch (uriType) {
case CATEGORIES:
id = sqlDB.insert(TABLE_NAME, null, contentValues);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return Uri.parse(BASE_URI + "/" + id);
}
@Override
public int delete(@NonNull Uri uri, String s, String[] strings) {
return 0;
}
@SuppressWarnings("ConstantConditions")
@Override
public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
Timber.d("Hello, bulk insert! (CategoryContentProvider)");
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
sqlDB.beginTransaction();
switch (uriType) {
case CATEGORIES:
for (ContentValues value : values) {
Timber.d("Inserting! %s", value);
sqlDB.insert(TABLE_NAME, null, value);
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
sqlDB.setTransactionSuccessful();
sqlDB.endTransaction();
getContext().getContentResolver().notifyChange(uri, null);
return values.length;
}
@SuppressWarnings("ConstantConditions")
@Override
public int update(@NonNull Uri uri, ContentValues contentValues, String selection,
String[] selectionArgs) {
/*
SQL Injection warnings: First, note that we're not exposing this to the
outside world (exported="false"). Even then, we should make sure to sanitize
all user input appropriately. Input that passes through ContentValues
should be fine. So only issues are those that pass in via concating.
In here, the only concat created argument is for id. It is cast to an int,
and will error out otherwise.
*/
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
int rowsUpdated;
switch (uriType) {
case CATEGORIES_ID:
if (TextUtils.isEmpty(selection)) {
int id = Integer.valueOf(uri.getLastPathSegment());
rowsUpdated = sqlDB.update(TABLE_NAME,
contentValues,
COLUMN_ID + " = ?",
new String[]{String.valueOf(id)});
} else {
throw new IllegalArgumentException(
"Parameter `selection` should be empty when updating an ID");
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType);
}
getContext().getContentResolver().notifyChange(uri, null);
return rowsUpdated;
}
}

View file

@ -0,0 +1,205 @@
package fr.free.nrw.commons.category
import android.content.ContentValues
import android.content.UriMatcher
import android.content.UriMatcher.NO_MATCH
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteQueryBuilder
import android.net.Uri
import android.text.TextUtils
import androidx.annotation.NonNull
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.di.CommonsDaggerContentProvider
import timber.log.Timber
import javax.inject.Inject
class CategoryContentProvider : CommonsDaggerContentProvider() {
private val uriMatcher = UriMatcher(NO_MATCH).apply {
addURI(BuildConfig.CATEGORY_AUTHORITY, BASE_PATH, CATEGORIES)
addURI(BuildConfig.CATEGORY_AUTHORITY, "${BASE_PATH}/#", CATEGORIES_ID)
}
@Inject
lateinit var dbOpenHelper: DBOpenHelper
@SuppressWarnings("ConstantConditions")
override fun query(uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
val queryBuilder = SQLiteQueryBuilder().apply {
tables = TABLE_NAME
}
val uriType = uriMatcher.match(uri)
val db = dbOpenHelper.readableDatabase
val cursor: Cursor? = when (uriType) {
CATEGORIES -> queryBuilder.query(
db,
projection,
selection,
selectionArgs,
null,
null,
sortOrder
)
CATEGORIES_ID -> queryBuilder.query(
db,
ALL_FIELDS,
"_id = ?",
arrayOf(uri.lastPathSegment),
null,
null,
sortOrder
)
else -> throw IllegalArgumentException("Unknown URI $uri")
}
cursor?.setNotificationUri(context?.contentResolver, uri)
return cursor
}
override fun getType(uri: Uri): String? {
return null
}
@SuppressWarnings("ConstantConditions")
override fun insert(uri: Uri, contentValues: ContentValues?): Uri? {
val uriType = uriMatcher.match(uri)
val sqlDB = dbOpenHelper.writableDatabase
val id: Long
when (uriType) {
CATEGORIES -> {
id = sqlDB.insert(TABLE_NAME, null, contentValues)
}
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
context?.contentResolver?.notifyChange(uri, null)
return Uri.parse("${Companion.BASE_URI}/$id")
}
@SuppressWarnings("ConstantConditions")
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
// Not implemented
return 0
}
@SuppressWarnings("ConstantConditions")
override fun bulkInsert(uri: Uri, values: Array<ContentValues>): Int {
Timber.d("Hello, bulk insert! (CategoryContentProvider)")
val uriType = uriMatcher.match(uri)
val sqlDB = dbOpenHelper.writableDatabase
sqlDB.beginTransaction()
when (uriType) {
CATEGORIES -> {
for (value in values) {
Timber.d("Inserting! %s", value)
sqlDB.insert(TABLE_NAME, null, value)
}
sqlDB.setTransactionSuccessful()
}
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
sqlDB.endTransaction()
context?.contentResolver?.notifyChange(uri, null)
return values.size
}
@SuppressWarnings("ConstantConditions")
override fun update(uri: Uri, contentValues: ContentValues?, selection: String?,
selectionArgs: Array<String>?): Int {
val uriType = uriMatcher.match(uri)
val sqlDB = dbOpenHelper.writableDatabase
val rowsUpdated: Int
when (uriType) {
CATEGORIES_ID -> {
if (TextUtils.isEmpty(selection)) {
val id = uri.lastPathSegment?.toInt()
?: throw IllegalArgumentException("Invalid ID")
rowsUpdated = sqlDB.update(TABLE_NAME,
contentValues,
"$COLUMN_ID = ?",
arrayOf(id.toString()))
} else {
throw IllegalArgumentException(
"Parameter `selection` should be empty when updating an ID")
}
}
else -> throw IllegalArgumentException("Unknown URI: $uri with type $uriType")
}
context?.contentResolver?.notifyChange(uri, null)
return rowsUpdated
}
companion object {
const val TABLE_NAME = "categories"
const val COLUMN_ID = "_id"
const val COLUMN_NAME = "name"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_THUMBNAIL = "thumbnail"
const val COLUMN_LAST_USED = "last_used"
const val COLUMN_TIMES_USED = "times_used"
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
val ALL_FIELDS = arrayOf(
COLUMN_ID,
COLUMN_NAME,
COLUMN_DESCRIPTION,
COLUMN_THUMBNAIL,
COLUMN_LAST_USED,
COLUMN_TIMES_USED
)
const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
"$COLUMN_ID INTEGER PRIMARY KEY," +
"$COLUMN_NAME TEXT," +
"$COLUMN_DESCRIPTION TEXT," +
"$COLUMN_THUMBNAIL TEXT," +
"$COLUMN_LAST_USED INTEGER," +
"$COLUMN_TIMES_USED INTEGER" +
");"
fun uriForId(id: Int): Uri {
return Uri.parse("${BASE_URI}/$id")
}
fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_TABLE_STATEMENT)
}
fun onDelete(db: SQLiteDatabase) {
db.execSQL(DROP_TABLE_STATEMENT)
onCreate(db)
}
fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
if (from == to) return
if (from < 4) {
// doesn't exist yet
onUpdate(db, from + 1, to)
} else if (from == 4) {
// table added in version 5
onCreate(db)
onUpdate(db, from + 1, to)
} else if (from == 5) {
onUpdate(db, from + 1, to)
} else if (from == 17) {
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description TEXT;")
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail TEXT;")
onUpdate(db, from + 1, to)
}
}
// For URI matcher
private const val CATEGORIES = 1
private const val CATEGORIES_ID = 2
private const val BASE_PATH = "categories"
val BASE_URI: Uri = Uri.parse("content://${BuildConfig.CATEGORY_AUTHORITY}/${Companion.BASE_PATH}")
}
}

View file

@ -1,207 +0,0 @@
package fr.free.nrw.commons.category;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.RemoteException;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Provider;
public class CategoryDao {
private final Provider<ContentProviderClient> clientProvider;
@Inject
public CategoryDao(@Named("category") Provider<ContentProviderClient> clientProvider) {
this.clientProvider = clientProvider;
}
public void save(Category category) {
ContentProviderClient db = clientProvider.get();
try {
if (category.getContentUri() == null) {
category.setContentUri(db.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
} else {
db.update(category.getContentUri(), toContentValues(category), null, null);
}
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
db.release();
}
}
/**
* Find persisted category in database, based on its name.
*
* @param name Category's name
* @return category from database, or null if not found
*/
@Nullable
Category find(String name) {
Cursor cursor = null;
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
CategoryContentProvider.BASE_URI,
Table.ALL_FIELDS,
Table.COLUMN_NAME + "=?",
new String[]{name},
null);
if (cursor != null && cursor.moveToFirst()) {
return fromCursor(cursor);
}
} catch (RemoteException e) {
// This feels lazy, but to hell with checked exceptions. :)
throw new RuntimeException(e);
} finally {
if (cursor != null) {
cursor.close();
}
db.release();
}
return null;
}
/**
* Retrieve recently-used categories, ordered by descending date.
*
* @return a list containing recent categories
*/
@NonNull
List<CategoryItem> recentCategories(int limit) {
List<CategoryItem> items = new ArrayList<>();
Cursor cursor = null;
ContentProviderClient db = clientProvider.get();
try {
cursor = db.query(
CategoryContentProvider.BASE_URI,
Table.ALL_FIELDS,
null,
new String[]{},
Table.COLUMN_LAST_USED + " DESC");
// fixme add a limit on the original query instead of falling out of the loop?
while (cursor != null && cursor.moveToNext()
&& cursor.getPosition() < limit) {
if (fromCursor(cursor).getName() != null ) {
items.add(new CategoryItem(fromCursor(cursor).getName(),
fromCursor(cursor).getDescription(), fromCursor(cursor).getThumbnail(),
false));
}
}
} catch (RemoteException e) {
throw new RuntimeException(e);
} finally {
if (cursor != null) {
cursor.close();
}
db.release();
}
return items;
}
@NonNull
Category fromCursor(Cursor cursor) {
// Hardcoding column positions!
return new Category(
CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(Table.COLUMN_ID))),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_NAME)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_DESCRIPTION)),
cursor.getString(cursor.getColumnIndex(Table.COLUMN_THUMBNAIL)),
new Date(cursor.getLong(cursor.getColumnIndex(Table.COLUMN_LAST_USED))),
cursor.getInt(cursor.getColumnIndex(Table.COLUMN_TIMES_USED))
);
}
private ContentValues toContentValues(Category category) {
ContentValues cv = new ContentValues();
cv.put(CategoryDao.Table.COLUMN_NAME, category.getName());
cv.put(Table.COLUMN_DESCRIPTION, category.getDescription());
cv.put(Table.COLUMN_THUMBNAIL, category.getThumbnail());
cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime());
cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed());
return cv;
}
public static class Table {
public static final String TABLE_NAME = "categories";
public static final String COLUMN_ID = "_id";
static final String COLUMN_NAME = "name";
static final String COLUMN_DESCRIPTION = "description";
static final String COLUMN_THUMBNAIL = "thumbnail";
static final String COLUMN_LAST_USED = "last_used";
static final String COLUMN_TIMES_USED = "times_used";
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
public static final String[] ALL_FIELDS = {
COLUMN_ID,
COLUMN_NAME,
COLUMN_DESCRIPTION,
COLUMN_THUMBNAIL,
COLUMN_LAST_USED,
COLUMN_TIMES_USED
};
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ COLUMN_ID + " INTEGER PRIMARY KEY,"
+ COLUMN_NAME + " STRING,"
+ COLUMN_DESCRIPTION + " STRING,"
+ COLUMN_THUMBNAIL + " STRING,"
+ COLUMN_LAST_USED + " INTEGER,"
+ COLUMN_TIMES_USED + " INTEGER"
+ ");";
public static void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
}
public static void onDelete(SQLiteDatabase db) {
db.execSQL(DROP_TABLE_STATEMENT);
onCreate(db);
}
public static void onUpdate(SQLiteDatabase db, int from, int to) {
if (from == to) {
return;
}
if (from < 4) {
// doesn't exist yet
from++;
onUpdate(db, from, to);
return;
}
if (from == 4) {
// table added in version 5
onCreate(db);
from++;
onUpdate(db, from, to);
return;
}
if (from == 5) {
from++;
onUpdate(db, from, to);
return;
}
if (from == 17) {
db.execSQL("ALTER TABLE categories ADD COLUMN description STRING;");
db.execSQL("ALTER TABLE categories ADD COLUMN thumbnail STRING;");
from++;
onUpdate(db, from, to);
return;
}
}
}
}

View file

@ -0,0 +1,194 @@
package fr.free.nrw.commons.category
import android.annotation.SuppressLint
import android.content.ContentProviderClient
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.os.RemoteException
import java.util.ArrayList
import java.util.Date
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Provider
class CategoryDao @Inject constructor(
@Named("category") private val clientProvider: Provider<ContentProviderClient>
) {
fun save(category: Category) {
val db = clientProvider.get()
try {
if (category.contentUri == null) {
category.contentUri = db.insert(
CategoryContentProvider.BASE_URI,
toContentValues(category)
)
} else {
db.update(
category.contentUri!!,
toContentValues(category),
null,
null
)
}
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
db.release()
}
}
/**
* Find persisted category in database, based on its name.
*
* @param name Category's name
* @return category from database, or null if not found
*/
fun find(name: String): Category? {
var cursor: Cursor? = null
val db = clientProvider.get()
try {
cursor = db.query(
CategoryContentProvider.BASE_URI,
ALL_FIELDS,
"${COLUMN_NAME}=?",
arrayOf(name),
null
)
if (cursor != null && cursor.moveToFirst()) {
return fromCursor(cursor)
}
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
cursor?.close()
db.release()
}
return null
}
/**
* Retrieve recently-used categories, ordered by descending date.
*
* @return a list containing recent categories
*/
fun recentCategories(limit: Int): List<CategoryItem> {
val items = ArrayList<CategoryItem>()
var cursor: Cursor? = null
val db = clientProvider.get()
try {
cursor = db.query(
CategoryContentProvider.BASE_URI,
ALL_FIELDS,
null,
emptyArray(),
"$COLUMN_LAST_USED DESC"
)
while (cursor != null && cursor.moveToNext() && cursor.position < limit) {
val category = fromCursor(cursor)
if (category.name != null) {
items.add(
CategoryItem(
category.name,
category.description,
category.thumbnail,
false
)
)
}
}
} catch (e: RemoteException) {
throw RuntimeException(e)
} finally {
cursor?.close()
db.release()
}
return items
}
@SuppressLint("Range")
fun fromCursor(cursor: Cursor): Category {
// Hardcoding column positions!
return Category(
CategoryContentProvider.uriForId(cursor.getInt(cursor.getColumnIndex(COLUMN_ID))),
cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
cursor.getString(cursor.getColumnIndex(COLUMN_DESCRIPTION)),
cursor.getString(cursor.getColumnIndex(COLUMN_THUMBNAIL)),
Date(cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_USED))),
cursor.getInt(cursor.getColumnIndex(COLUMN_TIMES_USED))
)
}
private fun toContentValues(category: Category): ContentValues {
return ContentValues().apply {
put(COLUMN_NAME, category.name)
put(COLUMN_DESCRIPTION, category.description)
put(COLUMN_THUMBNAIL, category.thumbnail)
put(COLUMN_LAST_USED, category.lastUsed?.time)
put(COLUMN_TIMES_USED, category.timesUsed)
}
}
companion object Table {
const val TABLE_NAME = "categories"
const val COLUMN_ID = "_id"
const val COLUMN_NAME = "name"
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_THUMBNAIL = "thumbnail"
const val COLUMN_LAST_USED = "last_used"
const val COLUMN_TIMES_USED = "times_used"
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
val ALL_FIELDS = arrayOf(
COLUMN_ID,
COLUMN_NAME,
COLUMN_DESCRIPTION,
COLUMN_THUMBNAIL,
COLUMN_LAST_USED,
COLUMN_TIMES_USED
)
const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS $TABLE_NAME"
const val CREATE_TABLE_STATEMENT = "CREATE TABLE $TABLE_NAME (" +
"$COLUMN_ID INTEGER PRIMARY KEY," +
"$COLUMN_NAME STRING," +
"$COLUMN_DESCRIPTION STRING," +
"$COLUMN_THUMBNAIL STRING," +
"$COLUMN_LAST_USED INTEGER," +
"$COLUMN_TIMES_USED INTEGER" +
");"
@SuppressLint("SQLiteString")
fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_TABLE_STATEMENT)
}
fun onDelete(db: SQLiteDatabase) {
db.execSQL(DROP_TABLE_STATEMENT)
onCreate(db)
}
@SuppressLint("SQLiteString")
fun onUpdate(db: SQLiteDatabase, from: Int, to: Int) {
if (from == to) return
if (from < 4) {
// doesn't exist yet
onUpdate(db, from + 1, to)
} else if (from == 4) {
// table added in version 5
onCreate(db)
onUpdate(db, from + 1, to)
} else if (from == 5) {
onUpdate(db, from + 1, to)
} else if (from == 17) {
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN description STRING;")
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN thumbnail STRING;")
onUpdate(db, from + 1, to)
}
}
}
}

View file

@ -1,236 +0,0 @@
package fr.free.nrw.commons.category;
import static fr.free.nrw.commons.category.CategoryClientKt.CATEGORY_PREFIX;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.FrameLayout;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import com.google.android.material.tabs.TabLayout;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.ViewPagerAdapter;
import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding;
import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment;
import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment;
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment;
import fr.free.nrw.commons.media.MediaDetailPagerFragment;
import fr.free.nrw.commons.theme.BaseActivity;
import java.util.ArrayList;
import java.util.List;
import fr.free.nrw.commons.wikidata.model.page.PageTitle;
/**
* This activity displays details of a particular category
* Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
* a particular category on wikimedia commons.
*/
public class CategoryDetailsActivity extends BaseActivity
implements MediaDetailPagerFragment.MediaDetailProvider, CategoryImagesCallback {
private FragmentManager supportFragmentManager;
private CategoriesMediaFragment categoriesMediaFragment;
private MediaDetailPagerFragment mediaDetails;
private String categoryName;
ViewPagerAdapter viewPagerAdapter;
private ActivityCategoryDetailsBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityCategoryDetailsBinding.inflate(getLayoutInflater());
final View view = binding.getRoot();
setContentView(view);
supportFragmentManager = getSupportFragmentManager();
viewPagerAdapter = new ViewPagerAdapter(getSupportFragmentManager());
binding.viewPager.setAdapter(viewPagerAdapter);
binding.viewPager.setOffscreenPageLimit(2);
binding.tabLayout.setupWithViewPager(binding.viewPager);
setSupportActionBar(binding.toolbarBinding.toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
setTabs();
setPageTitle();
}
/**
* This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
* Set the fragments according to the tab selected in the viewPager.
*/
private void setTabs() {
List<Fragment> fragmentList = new ArrayList<>();
List<String> titleList = new ArrayList<>();
categoriesMediaFragment = new CategoriesMediaFragment();
SubCategoriesFragment subCategoryListFragment = new SubCategoriesFragment();
ParentCategoriesFragment parentCategoriesFragment = new ParentCategoriesFragment();
categoryName = getIntent().getStringExtra("categoryName");
if (getIntent() != null && categoryName != null) {
Bundle arguments = new Bundle();
arguments.putString("categoryName", categoryName);
categoriesMediaFragment.setArguments(arguments);
subCategoryListFragment.setArguments(arguments);
parentCategoriesFragment.setArguments(arguments);
}
fragmentList.add(categoriesMediaFragment);
titleList.add("MEDIA");
fragmentList.add(subCategoryListFragment);
titleList.add("SUBCATEGORIES");
fragmentList.add(parentCategoriesFragment);
titleList.add("PARENT CATEGORIES");
viewPagerAdapter.setTabData(fragmentList, titleList);
viewPagerAdapter.notifyDataSetChanged();
}
/**
* Gets the passed categoryName from the intents and displays it as the page title
*/
private void setPageTitle() {
if (getIntent() != null && getIntent().getStringExtra("categoryName") != null) {
setTitle(getIntent().getStringExtra("categoryName"));
}
}
/**
* This method is called onClick of media inside category details (CategoryImageListFragment).
*/
@Override
public void onMediaClicked(int position) {
binding.tabLayout.setVisibility(View.GONE);
binding.viewPager.setVisibility(View.GONE);
binding.mediaContainer.setVisibility(View.VISIBLE);
if (mediaDetails == null || !mediaDetails.isVisible()) {
// set isFeaturedImage true for featured images, to include author field on media detail
mediaDetails = MediaDetailPagerFragment.newInstance(false, true);
FragmentManager supportFragmentManager = getSupportFragmentManager();
supportFragmentManager
.beginTransaction()
.replace(R.id.mediaContainer, mediaDetails)
.addToBackStack(null)
.commit();
supportFragmentManager.executePendingTransactions();
}
mediaDetails.showImage(position);
}
/**
* Consumers should be simply using this method to use this activity.
* @param context A Context of the application package implementing this class.
* @param categoryName Name of the category for displaying its details
*/
public static void startYourself(Context context, String categoryName) {
Intent intent = new Intent(context, CategoryDetailsActivity.class);
intent.putExtra("categoryName", categoryName);
context.startActivity(intent);
}
/**
* This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
* @param i It is the index of which media object is to be returned which is same as
* current index of viewPager.
* @return Media Object
*/
@Override
public Media getMediaAtPosition(int i) {
return categoriesMediaFragment.getMediaAtPosition(i);
}
/**
* This method is called on from getCount of MediaDetailPagerFragment
* The viewpager will contain same number of media items as that of media elements in adapter.
* @return Total Media count in the adapter
*/
@Override
public int getTotalMediaCount() {
return categoriesMediaFragment.getTotalMediaCount();
}
@Override
public Integer getContributionStateAt(int position) {
return null;
}
/**
* Reload media detail fragment once media is nominated
*
* @param index item position that has been nominated
*/
@Override
public void refreshNominatedMedia(int index) {
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
onBackPressed();
onMediaClicked(index);
}
}
/**
* This method inflates the menu in the toolbar
*/
@Override
public boolean onCreateOptionsMenu(Menu menu) {
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.fragment_category_detail, menu);
return super.onCreateOptionsMenu(menu);
}
/**
* This method handles the logic on ItemSelect in toolbar menu
* Currently only 1 choice is available to open category details page in browser
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle item selection
switch (item.getItemId()) {
case R.id.menu_browser_current_category:
PageTitle title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName);
Utils.handleWebUrl(this, Uri.parse(title.getCanonicalUri()));
return true;
case android.R.id.home:
onBackPressed();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
/**
* This method is called on backPressed of anyFragment in the activity.
* If condition is called when mediaDetailFragment is opened.
*/
@Override
public void onBackPressed() {
if (supportFragmentManager.getBackStackEntryCount() == 1){
binding.tabLayout.setVisibility(View.VISIBLE);
binding.viewPager.setVisibility(View.VISIBLE);
binding.mediaContainer.setVisibility(View.GONE);
}
super.onBackPressed();
}
/**
* This method is called on success of API call for Images inside a category.
* The viewpager will notified that number of items have changed.
*/
@Override
public void viewPagerNotifyDataSetChanged() {
if (mediaDetails!=null){
mediaDetails.notifyDataSetChanged();
}
}
}

View file

@ -0,0 +1,216 @@
package fr.free.nrw.commons.category
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.Utils
import fr.free.nrw.commons.ViewPagerAdapter
import fr.free.nrw.commons.databinding.ActivityCategoryDetailsBinding
import fr.free.nrw.commons.explore.categories.media.CategoriesMediaFragment
import fr.free.nrw.commons.explore.categories.parent.ParentCategoriesFragment
import fr.free.nrw.commons.explore.categories.sub.SubCategoriesFragment
import fr.free.nrw.commons.media.MediaDetailPagerFragment
import fr.free.nrw.commons.theme.BaseActivity
/**
* This activity displays details of a particular category
* Its generic and simply takes the name of category name in its start intent to load all images, subcategories in
* a particular category on wikimedia commons.
*/
class CategoryDetailsActivity : BaseActivity(),
MediaDetailPagerFragment.MediaDetailProvider,
CategoryImagesCallback {
private lateinit var supportFragmentManager: FragmentManager
private lateinit var categoriesMediaFragment: CategoriesMediaFragment
private var mediaDetails: MediaDetailPagerFragment? = null
private var categoryName: String? = null
private lateinit var viewPagerAdapter: ViewPagerAdapter
private lateinit var binding: ActivityCategoryDetailsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCategoryDetailsBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
supportFragmentManager = getSupportFragmentManager()
viewPagerAdapter = ViewPagerAdapter(supportFragmentManager)
binding.viewPager.adapter = viewPagerAdapter
binding.viewPager.offscreenPageLimit = 2
binding.tabLayout.setupWithViewPager(binding.viewPager)
setSupportActionBar(binding.toolbarBinding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setTabs()
setPageTitle()
}
/**
* This activity contains 3 tabs and a viewpager. This method is used to set the titles of tab,
* Set the fragments according to the tab selected in the viewPager.
*/
private fun setTabs() {
val fragmentList = mutableListOf<Fragment>()
val titleList = mutableListOf<String>()
categoriesMediaFragment = CategoriesMediaFragment()
val subCategoryListFragment = SubCategoriesFragment()
val parentCategoriesFragment = ParentCategoriesFragment()
categoryName = intent?.getStringExtra("categoryName")
if (intent != null && categoryName != null) {
val arguments = Bundle().apply {
putString("categoryName", categoryName)
}
categoriesMediaFragment.arguments = arguments
subCategoryListFragment.arguments = arguments
parentCategoriesFragment.arguments = arguments
}
fragmentList.add(categoriesMediaFragment)
titleList.add("MEDIA")
fragmentList.add(subCategoryListFragment)
titleList.add("SUBCATEGORIES")
fragmentList.add(parentCategoriesFragment)
titleList.add("PARENT CATEGORIES")
viewPagerAdapter.setTabData(fragmentList, titleList)
viewPagerAdapter.notifyDataSetChanged()
}
/**
* Gets the passed categoryName from the intents and displays it as the page title
*/
private fun setPageTitle() {
intent?.getStringExtra("categoryName")?.let {
title = it
}
}
/**
* This method is called onClick of media inside category details (CategoryImageListFragment).
*/
override fun onMediaClicked(position: Int) {
binding.tabLayout.visibility = View.GONE
binding.viewPager.visibility = View.GONE
binding.mediaContainer.visibility = View.VISIBLE
if (mediaDetails == null || mediaDetails?.isVisible == false) {
// set isFeaturedImage true for featured images, to include author field on media detail
mediaDetails = MediaDetailPagerFragment.newInstance(false, true)
supportFragmentManager.beginTransaction()
.replace(R.id.mediaContainer, mediaDetails!!)
.addToBackStack(null)
.commit()
supportFragmentManager.executePendingTransactions()
}
mediaDetails?.showImage(position)
}
companion object {
/**
* Consumers should be simply using this method to use this activity.
* @param context A Context of the application package implementing this class.
* @param categoryName Name of the category for displaying its details
*/
fun startYourself(context: Context?, categoryName: String) {
val intent = Intent(context, CategoryDetailsActivity::class.java).apply {
putExtra("categoryName", categoryName)
}
context?.startActivity(intent)
}
}
/**
* This method is called mediaDetailPagerFragment. It returns the Media Object at that Index
* @param i It is the index of which media object is to be returned which is same as
* current index of viewPager.
* @return Media Object
*/
override fun getMediaAtPosition(i: Int): Media? {
return categoriesMediaFragment.getMediaAtPosition(i)
}
/**
* This method is called on from getCount of MediaDetailPagerFragment
* The viewpager will contain same number of media items as that of media elements in adapter.
* @return Total Media count in the adapter
*/
override fun getTotalMediaCount(): Int {
return categoriesMediaFragment.getTotalMediaCount()
}
override fun getContributionStateAt(position: Int): Int? {
return null
}
/**
* Reload media detail fragment once media is nominated
*
* @param index item position that has been nominated
*/
override fun refreshNominatedMedia(index: Int) {
if (supportFragmentManager.backStackEntryCount == 1) {
onBackPressed()
onMediaClicked(index)
}
}
/**
* This method inflates the menu in the toolbar
*/
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.fragment_category_detail, menu)
return super.onCreateOptionsMenu(menu)
}
/**
* This method handles the logic on ItemSelect in toolbar menu
* Currently only 1 choice is available to open category details page in browser
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_browser_current_category -> {
val title = Utils.getPageTitle(CATEGORY_PREFIX + categoryName)
Utils.handleWebUrl(this, Uri.parse(title.canonicalUri))
true
}
android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
}
/**
* This method is called on backPressed of anyFragment in the activity.
* If condition is called when mediaDetailFragment is opened.
*/
@Deprecated("This method has been deprecated in favor of using the" +
"{@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}." +
"The OnBackPressedDispatcher controls how back button events are dispatched" +
"to one or more {@link OnBackPressedCallback} objects.")
override fun onBackPressed() {
if (supportFragmentManager.backStackEntryCount == 1) {
binding.tabLayout.visibility = View.VISIBLE
binding.viewPager.visibility = View.VISIBLE
binding.mediaContainer.visibility = View.GONE
}
super.onBackPressed()
}
/**
* This method is called on success of API call for Images inside a category.
* The viewpager will notified that number of items have changed.
*/
override fun viewPagerNotifyDataSetChanged() {
mediaDetails?.notifyDataSetChanged()
}
}

View file

@ -1,123 +0,0 @@
package fr.free.nrw.commons.category;
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_CATEGORY;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Observable;
import io.reactivex.Single;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
public class CategoryEditHelper {
private final NotificationHelper notificationHelper;
public final PageEditClient pageEditClient;
private final ViewUtilWrapper viewUtil;
private final String username;
@Inject
public CategoryEditHelper(NotificationHelper notificationHelper,
@Named("commons-page-edit") PageEditClient pageEditClient,
ViewUtilWrapper viewUtil,
@Named("username") String username) {
this.notificationHelper = notificationHelper;
this.pageEditClient = pageEditClient;
this.viewUtil = viewUtil;
this.username = username;
}
/**
* Public interface to edit categories
* @param context
* @param media
* @param categories
* @return
*/
public Single<Boolean> makeCategoryEdit(Context context, Media media, List<String> categories,
final String wikiText) {
viewUtil.showShortToast(context, context.getString(R.string.category_edit_helper_make_edit_toast));
return addCategory(media, categories, wikiText)
.flatMapSingle(result -> Single.just(showCategoryEditNotification(context, media, result)))
.firstOrError();
}
/**
* Rebuilds the WikiText with new categpries and post it on server
*
* @param media
* @param categories to be added
* @return
*/
private Observable<Boolean> addCategory(Media media, List<String> categories,
final String wikiText) {
Timber.d("thread is category adding %s", Thread.currentThread().getName());
String summary = "Adding categories";
final StringBuilder buffer = new StringBuilder();
final String wikiTextWithoutCategory;
//If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
if (wikiText.contains("Uncategorized")) {
wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("Uncategorized"));
} else if (wikiText.contains("[[Category")) {
wikiTextWithoutCategory = wikiText.substring(0, wikiText.indexOf("[[Category"));
} else {
wikiTextWithoutCategory = "";
}
if (categories != null && !categories.isEmpty()) {
//If the categories list is empty, when reading the categories of a picture,
// the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
// So that after selected some category,"None selected" should be removed from list
for (int i = 0; i < categories.size(); i++) {
if (!categories.get(i).equals("None selected")//Not to add "None selected" as category to wikiText
|| !wikiText.contains("Uncategorized")) {
buffer.append("[[Category:").append(categories.get(i)).append("]]\n");
}
}
categories.remove("None selected");
} else {
buffer.append("{{subst:unc}}");
}
final String appendText = wikiTextWithoutCategory + buffer;
return pageEditClient.edit(media.getFilename(), appendText + "\n", summary);
}
private boolean showCategoryEditNotification(Context context, Media media, boolean result) {
String message;
String title = context.getString(R.string.category_edit_helper_show_edit_title);
if (result) {
title += ": " + context.getString(R.string.category_edit_helper_show_edit_title_success);
StringBuilder categoriesInMessage = new StringBuilder();
List<String> mediaCategoryList = media.getCategories();
for (String category : mediaCategoryList) {
categoriesInMessage.append(category);
if (category.equals(mediaCategoryList.get(mediaCategoryList.size()-1))) {
continue;
}
categoriesInMessage.append(",");
}
message = context.getResources().getQuantityString(R.plurals.category_edit_helper_show_edit_message_if, mediaCategoryList.size(), categoriesInMessage.toString());
} else {
title += ": " + context.getString(R.string.category_edit_helper_show_edit_title);
message = context.getString(R.string.category_edit_helper_edit_message_else) ;
}
String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_CATEGORY, browserIntent);
return result;
}
public interface Callback {
boolean updateCategoryDisplay(List<String> categories);
}
}

View file

@ -0,0 +1,144 @@
package fr.free.nrw.commons.category
import android.content.Context
import android.content.Intent
import android.net.Uri
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.actions.PageEditClient
import fr.free.nrw.commons.notification.NotificationHelper
import fr.free.nrw.commons.utils.ViewUtilWrapper
import io.reactivex.Observable
import io.reactivex.Single
import javax.inject.Inject
import javax.inject.Named
import timber.log.Timber
class CategoryEditHelper @Inject constructor(
private val notificationHelper: NotificationHelper,
@Named("commons-page-edit") val pageEditClient: PageEditClient,
private val viewUtil: ViewUtilWrapper,
@Named("username") private val username: String
) {
/**
* Public interface to edit categories
* @param context
* @param media
* @param categories
* @return
*/
fun makeCategoryEdit(
context: Context,
media: Media,
categories: List<String>,
wikiText: String
): Single<Boolean> {
viewUtil.showShortToast(
context,
context.getString(R.string.category_edit_helper_make_edit_toast)
)
return addCategory(media, categories, wikiText)
.flatMapSingle { result ->
Single.just(showCategoryEditNotification(context, media, result))
}
.firstOrError()
}
/**
* Rebuilds the WikiText with new categories and post it on server
*
* @param media
* @param categories to be added
* @return
*/
private fun addCategory(
media: Media,
categories: List<String>?,
wikiText: String
): Observable<Boolean> {
Timber.d("thread is category adding %s", Thread.currentThread().name)
val summary = "Adding categories"
val buffer = StringBuilder()
// If the picture was uploaded without a category, the wikitext will contain "Uncategorized" instead of "[[Category"
val wikiTextWithoutCategory: String = when {
wikiText.contains("Uncategorized") -> wikiText.substring(0, wikiText.indexOf("Uncategorized"))
wikiText.contains("[[Category") -> wikiText.substring(0, wikiText.indexOf("[[Category"))
else -> ""
}
if (!categories.isNullOrEmpty()) {
// If the categories list is empty, when reading the categories of a picture,
// the code will add "None selected" to categories list in order to see in picture's categories with "None selected".
// So that after selecting some category, "None selected" should be removed from list
for (category in categories) {
if (category != "None selected" || !wikiText.contains("Uncategorized")) {
buffer.append("[[Category:").append(category).append("]]\n")
}
}
categories.dropWhile {
it == "None selected"
}
} else {
buffer.append("{{subst:unc}}")
}
val appendText = wikiTextWithoutCategory + buffer
return pageEditClient.edit(media.filename!!, "$appendText\n", summary)
}
private fun showCategoryEditNotification(
context: Context,
media: Media,
result: Boolean
): Boolean {
val title: String
val message: String
if (result) {
title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
context.getString(R.string.category_edit_helper_show_edit_title_success)
val categoriesInMessage = StringBuilder()
val mediaCategoryList = media.categories
for ((index, category) in mediaCategoryList?.withIndex()!!) {
categoriesInMessage.append(category)
if (index != mediaCategoryList.size - 1) {
categoriesInMessage.append(",")
}
}
message = context.resources.getQuantityString(
R.plurals.category_edit_helper_show_edit_message_if,
mediaCategoryList.size,
categoriesInMessage.toString()
)
} else {
title = context.getString(R.string.category_edit_helper_show_edit_title) + ": " +
context.getString(R.string.category_edit_helper_show_edit_title)
message = context.getString(R.string.category_edit_helper_edit_message_else)
}
val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
notificationHelper.showNotification(
context,
title,
message,
NOTIFICATION_EDIT_CATEGORY,
browserIntent
)
return result
}
interface Callback {
fun updateCategoryDisplay(categories: List<String>?): Boolean
}
companion object {
const val NOTIFICATION_EDIT_CATEGORY = 1
}
}

View file

@ -1,13 +0,0 @@
package fr.free.nrw.commons.category;
/**
* Callback for notifying the viewpager that the number of items have changed
* and for requesting more images when the viewpager has been scrolled to its end.
*/
public interface CategoryImagesCallback {
void viewPagerNotifyDataSetChanged();
void onMediaClicked(int position);
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.category
interface CategoryImagesCallback {
fun viewPagerNotifyDataSetChanged()
fun onMediaClicked(position: Int)
}

View file

@ -1,119 +0,0 @@
package fr.free.nrw.commons.category;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.facebook.drawee.view.SimpleDraweeView;
import java.util.ArrayList;
import java.util.List;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
/**
* This is created to only display UI implementation. Needs to be changed in real implementation
*/
public class GridViewAdapter extends ArrayAdapter {
private List<Media> data;
public GridViewAdapter(Context context, int layoutResourceId, List<Media> data) {
super(context, layoutResourceId, data);
this.data = data;
}
/**
* Adds more item to the list
* Its triggered on scrolling down in the list
* @param images
*/
public void addItems(List<Media> images) {
if (data == null) {
data = new ArrayList<>();
}
data.addAll(images);
notifyDataSetChanged();
}
/**
* Check the first item in the new list with old list and returns true if they are same
* Its triggered on successful response of the fetch images API.
* @param images
*/
public boolean containsAll(List<Media> images){
if (images == null || images.isEmpty()) {
return false;
}
if (data == null) {
data = new ArrayList<>();
return false;
}
if (data.isEmpty()) {
return false;
}
String fileName = data.get(0).getFilename();
String imageName = images.get(0).getFilename();
return imageName.equals(fileName);
}
@Override
public boolean isEmpty() {
return data == null || data.isEmpty();
}
/**
* Sets up the UI for the category image item
* @param position
* @param convertView
* @param parent
* @return
*/
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = LayoutInflater.from(getContext()).inflate(R.layout.layout_category_images, null);
}
Media item = data.get(position);
SimpleDraweeView imageView = convertView.findViewById(R.id.categoryImageView);
TextView fileName = convertView.findViewById(R.id.categoryImageTitle);
TextView uploader = convertView.findViewById(R.id.categoryImageAuthor);
fileName.setText(item.getMostRelevantCaption());
setUploaderView(item, uploader);
imageView.setImageURI(item.getThumbUrl());
return convertView;
}
/**
* @return the Media item at the given position
*/
@Nullable
@Override
public Media getItem(int position) {
return data.get(position);
}
/**
* Shows author information if its present
* @param item
* @param uploader
*/
private void setUploaderView(Media item, TextView uploader) {
if (!TextUtils.isEmpty(item.getAuthor())) {
uploader.setVisibility(View.VISIBLE);
uploader.setText(getContext().getString(R.string.image_uploaded_by, item.getUser()));
} else {
uploader.setVisibility(View.GONE);
}
}
}

View file

@ -0,0 +1,111 @@
package fr.free.nrw.commons.category
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import com.facebook.drawee.view.SimpleDraweeView
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
/**
* This is created to only display UI implementation. Needs to be changed in real implementation
*/
class GridViewAdapter(
context: Context,
layoutResourceId: Int,
private var data: MutableList<Media>?
) : ArrayAdapter<Media>(context, layoutResourceId, data ?: mutableListOf()) {
/**
* Adds more items to the list
* It's triggered on scrolling down in the list
* @param images
*/
fun addItems(images: List<Media>) {
if (data == null) {
data = mutableListOf()
}
data?.addAll(images)
notifyDataSetChanged()
}
/**
* Checks the first item in the new list with the old list and returns true if they are the same
* It's triggered on a successful response of the fetch images API.
* @param images
*/
fun containsAll(images: List<Media>?): Boolean {
if (images.isNullOrEmpty()) {
return false
}
if (data.isNullOrEmpty()) {
data = mutableListOf()
return false
}
val fileName = data?.get(0)?.filename
val imageName = images[0].filename
return imageName == fileName
}
override fun isEmpty(): Boolean {
return data.isNullOrEmpty()
}
/**
* Sets up the UI for the category image item
* @param position
* @param convertView
* @param parent
* @return
*/
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(
R.layout.layout_category_images,
parent,
false
)
val item = data?.get(position)
val imageView = view.findViewById<SimpleDraweeView>(R.id.categoryImageView)
val fileName = view.findViewById<TextView>(R.id.categoryImageTitle)
val uploader = view.findViewById<TextView>(R.id.categoryImageAuthor)
item?.let {
fileName.text = it.mostRelevantCaption
setUploaderView(it, uploader)
imageView.setImageURI(it.thumbUrl)
}
return view
}
/**
* @return the Media item at the given position
*/
override fun getItem(position: Int): Media? {
return data?.get(position)
}
/**
* Shows author information if it's present
* @param item
* @param uploader
*/
@SuppressLint("StringFormatInvalid")
private fun setUploaderView(item: Media, uploader: TextView) {
if (!item.author.isNullOrEmpty()) {
uploader.visibility = View.VISIBLE
uploader.text = context.getString(
R.string.image_uploaded_by,
item.user
)
} else {
uploader.visibility = View.GONE
}
}
}

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.category;
import java.util.List;
public interface OnCategoriesSaveHandler {
void onCategoriesSave(List<String> categories);
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.category
interface OnCategoriesSaveHandler {
fun onCategoriesSave(categories: List<String>)
}

View file

@ -3,6 +3,7 @@ package fr.free.nrw.commons.contributions
import androidx.paging.PagedList.BoundaryCallback import androidx.paging.PagedList.BoundaryCallback
import fr.free.nrw.commons.auth.SessionManager import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.di.CommonsApplicationModule import fr.free.nrw.commons.di.CommonsApplicationModule
import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import io.reactivex.Scheduler import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
@ -20,7 +21,7 @@ class ContributionBoundaryCallback
private val repository: ContributionsRepository, private val repository: ContributionsRepository,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val mediaClient: MediaClient, private val mediaClient: MediaClient,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler,
) : BoundaryCallback<Contribution>() { ) : BoundaryCallback<Contribution>() {
private val compositeDisposable: CompositeDisposable = CompositeDisposable() private val compositeDisposable: CompositeDisposable = CompositeDisposable()
var userName: String? = null var userName: String? = null

View file

@ -7,6 +7,7 @@ import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
@ -45,7 +46,8 @@ public class ContributionController {
private boolean isInAppCameraUpload; private boolean isInAppCameraUpload;
public LocationPermissionCallback locationPermissionCallback; public LocationPermissionCallback locationPermissionCallback;
private LocationPermissionsHelper locationPermissionsHelper; private LocationPermissionsHelper locationPermissionsHelper;
LiveData<PagedList<Contribution>> failedAndPendingContributionList; // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// LiveData<PagedList<Contribution>> failedAndPendingContributionList;
LiveData<PagedList<Contribution>> pendingContributionList; LiveData<PagedList<Contribution>> pendingContributionList;
LiveData<PagedList<Contribution>> failedContributionList; LiveData<PagedList<Contribution>> failedContributionList;
@ -64,10 +66,11 @@ public class ContributionController {
* Check for permissions and initiate camera click * Check for permissions and initiate camera click
*/ */
public void initiateCameraPick(Activity activity, public void initiateCameraPick(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) { ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true); boolean useExtStorage = defaultKvStore.getBoolean("useExternalStorage", true);
if (!useExtStorage) { if (!useExtStorage) {
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
return; return;
} }
@ -75,17 +78,17 @@ public class ContributionController {
() -> { () -> {
if (defaultKvStore.getBoolean("inAppCameraFirstRun")) { if (defaultKvStore.getBoolean("inAppCameraFirstRun")) {
defaultKvStore.putBoolean("inAppCameraFirstRun", false); defaultKvStore.putBoolean("inAppCameraFirstRun", false);
askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher); askUserToAllowLocationAccess(activity, inAppCameraLocationPermissionLauncher, resultLauncher);
} else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) { } else if (defaultKvStore.getBoolean("inAppCameraLocationPref")) {
createDialogsAndHandleLocationPermissions(activity, createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher); inAppCameraLocationPermissionLauncher, resultLauncher);
} else { } else {
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
} }
}, },
R.string.storage_permission_title, R.string.storage_permission_title,
R.string.write_storage_permission_rationale, R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE); PermissionUtils.getPERMISSIONS_STORAGE());
} }
/** /**
@ -94,7 +97,8 @@ public class ContributionController {
* @param activity * @param activity
*/ */
private void createDialogsAndHandleLocationPermissions(Activity activity, private void createDialogsAndHandleLocationPermissions(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) { ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
locationPermissionCallback = new LocationPermissionCallback() { locationPermissionCallback = new LocationPermissionCallback() {
@Override @Override
public void onLocationPermissionDenied(String toastMessage) { public void onLocationPermissionDenied(String toastMessage) {
@ -103,16 +107,16 @@ public class ContributionController {
toastMessage, toastMessage,
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show(); ).show();
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
} }
@Override @Override
public void onLocationPermissionGranted() { public void onLocationPermissionGranted() {
if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) { if (!locationPermissionsHelper.isLocationAccessToAppsTurnedOn()) {
showLocationOffDialog(activity, R.string.in_app_camera_needs_location, showLocationOffDialog(activity, R.string.in_app_camera_needs_location,
R.string.in_app_camera_location_unavailable); R.string.in_app_camera_location_unavailable, resultLauncher);
} else { } else {
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
} }
} }
}; };
@ -135,9 +139,10 @@ public class ContributionController {
* @param activity Activity reference * @param activity Activity reference
* @param dialogTextResource Resource id of text to be shown in dialog * @param dialogTextResource Resource id of text to be shown in dialog
* @param toastTextResource Resource id of text to be shown in toast * @param toastTextResource Resource id of text to be shown in toast
* @param resultLauncher
*/ */
private void showLocationOffDialog(Activity activity, int dialogTextResource, private void showLocationOffDialog(Activity activity, int dialogTextResource,
int toastTextResource) { int toastTextResource, ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil DialogUtil
.showAlertDialog(activity, .showAlertDialog(activity,
activity.getString(R.string.ask_to_turn_location_on), activity.getString(R.string.ask_to_turn_location_on),
@ -148,25 +153,26 @@ public class ContributionController {
() -> { () -> {
Toast.makeText(activity, activity.getString(toastTextResource), Toast.makeText(activity, activity.getString(toastTextResource),
Toast.LENGTH_LONG).show(); Toast.LENGTH_LONG).show();
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
} }
); );
} }
public void handleShowRationaleFlowCameraLocation(Activity activity, public void handleShowRationaleFlowCameraLocation(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) { ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title), DialogUtil.showAlertDialog(activity, activity.getString(R.string.location_permission_title),
activity.getString(R.string.in_app_camera_location_permission_rationale), activity.getString(R.string.in_app_camera_location_permission_rationale),
activity.getString(android.R.string.ok), activity.getString(android.R.string.ok),
activity.getString(android.R.string.cancel), activity.getString(android.R.string.cancel),
() -> { () -> {
createDialogsAndHandleLocationPermissions(activity, createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher); inAppCameraLocationPermissionLauncher, resultLauncher);
}, },
() -> locationPermissionCallback.onLocationPermissionDenied( () -> locationPermissionCallback.onLocationPermissionDenied(
activity.getString(R.string.in_app_camera_location_permission_denied)), activity.getString(R.string.in_app_camera_location_permission_denied)),
null, null
false); );
} }
/** /**
@ -181,7 +187,8 @@ public class ContributionController {
* @param activity * @param activity
*/ */
private void askUserToAllowLocationAccess(Activity activity, private void askUserToAllowLocationAccess(Activity activity,
ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher) { ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher,
ActivityResultLauncher<Intent> resultLauncher) {
DialogUtil.showAlertDialog(activity, DialogUtil.showAlertDialog(activity,
activity.getString(R.string.in_app_camera_location_permission_title), activity.getString(R.string.in_app_camera_location_permission_title),
activity.getString(R.string.in_app_camera_location_access_explanation), activity.getString(R.string.in_app_camera_location_access_explanation),
@ -190,47 +197,45 @@ public class ContributionController {
() -> { () -> {
defaultKvStore.putBoolean("inAppCameraLocationPref", true); defaultKvStore.putBoolean("inAppCameraLocationPref", true);
createDialogsAndHandleLocationPermissions(activity, createDialogsAndHandleLocationPermissions(activity,
inAppCameraLocationPermissionLauncher); inAppCameraLocationPermissionLauncher, resultLauncher);
}, },
() -> { () -> {
ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied); ViewUtil.showLongToast(activity, R.string.in_app_camera_location_permission_denied);
defaultKvStore.putBoolean("inAppCameraLocationPref", false); defaultKvStore.putBoolean("inAppCameraLocationPref", false);
initiateCameraUpload(activity); initiateCameraUpload(activity, resultLauncher);
}, },
null, null
true); );
} }
/** /**
* Initiate gallery picker * Initiate gallery picker
*/ */
public void initiateGalleryPick(final Activity activity, final boolean allowMultipleUploads) { public void initiateGalleryPick(final Activity activity, ActivityResultLauncher<Intent> resultLauncher, final boolean allowMultipleUploads) {
initiateGalleryUpload(activity, allowMultipleUploads); initiateGalleryUpload(activity, resultLauncher, allowMultipleUploads);
} }
/** /**
* Initiate gallery picker with permission * Initiate gallery picker with permission
*/ */
public void initiateCustomGalleryPickWithPermission(final Activity activity) { public void initiateCustomGalleryPickWithPermission(final Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
setPickerConfiguration(activity, true); setPickerConfiguration(activity, true);
PermissionUtils.checkPermissionsAndPerformAction(activity, PermissionUtils.checkPermissionsAndPerformAction(activity,
() -> FilePicker.openCustomSelector(activity, 0), () -> FilePicker.openCustomSelector(activity, resultLauncher, 0),
R.string.storage_permission_title, R.string.storage_permission_title,
R.string.write_storage_permission_rationale, R.string.write_storage_permission_rationale,
PermissionUtils.PERMISSIONS_STORAGE); PermissionUtils.getPERMISSIONS_STORAGE());
} }
/** /**
* Open chooser for gallery uploads * Open chooser for gallery uploads
*/ */
private void initiateGalleryUpload(final Activity activity, private void initiateGalleryUpload(final Activity activity, ActivityResultLauncher<Intent> resultLauncher,
final boolean allowMultipleUploads) { final boolean allowMultipleUploads) {
setPickerConfiguration(activity, allowMultipleUploads); setPickerConfiguration(activity, allowMultipleUploads);
boolean openDocumentIntentPreferred = defaultKvStore.getBoolean( FilePicker.openGallery(activity, resultLauncher, 0, isDocumentPhotoPickerPreferred());
"openDocumentPhotoPickerPref", true);
FilePicker.openGallery(activity, 0, openDocumentIntentPreferred);
} }
/** /**
@ -247,22 +252,43 @@ public class ContributionController {
/** /**
* Initiate camera upload by opening camera * Initiate camera upload by opening camera
*/ */
private void initiateCameraUpload(Activity activity) { private void initiateCameraUpload(Activity activity, ActivityResultLauncher<Intent> resultLauncher) {
setPickerConfiguration(activity, false); setPickerConfiguration(activity, false);
if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) { if (defaultKvStore.getBoolean("inAppCameraLocationPref", false)) {
locationBeforeImageCapture = locationManager.getLastLocation(); locationBeforeImageCapture = locationManager.getLastLocation();
} }
isInAppCameraUpload = true; isInAppCameraUpload = true;
FilePicker.openCameraForImage(activity, 0); FilePicker.openCameraForImage(activity, resultLauncher, 0);
}
private boolean isDocumentPhotoPickerPreferred(){
return defaultKvStore.getBoolean(
"openDocumentPhotoPickerPref", true);
}
public void onPictureReturnedFromGallery(ActivityResult result, Activity activity, FilePicker.Callbacks callbacks){
if(isDocumentPhotoPickerPreferred()){
FilePicker.onPictureReturnedFromDocuments(result, activity, callbacks);
} else {
FilePicker.onPictureReturnedFromGallery(result, activity, callbacks);
}
}
public void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
FilePicker.onPictureReturnedFromCustomSelector(result, activity, callbacks);
}
public void onPictureReturnedFromCamera(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) {
FilePicker.onPictureReturnedFromCamera(result, activity, callbacks);
} }
/** /**
* Attaches callback for file picker. * Attaches callback for file picker.
*/ */
public void handleActivityResult(Activity activity, int requestCode, int resultCode, public void handleActivityResultWithCallback(Activity activity, FilePicker.HandleActivityResult handleActivityResult) {
Intent data) {
FilePicker.handleActivityResult(requestCode, resultCode, data, activity, handleActivityResult.onHandleActivityResult(new DefaultCallback() {
new DefaultCallback() {
@Override @Override
public void onCanceled(final ImageSource source, final int type) { public void onCanceled(final ImageSource source, final int type) {
@ -358,21 +384,22 @@ public class ContributionController {
} }
/** /**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and * Fetches the contributions with the state "IN_PROGRESS", "QUEUED", "PAUSED" and "FAILED" and
* then it populates the `failedAndPendingContributionList`. * then it populates the `failedAndPendingContributionList`.
**/ **/
void getFailedAndPendingContributions() { // void getFailedAndPendingContributions() {
final PagedList.Config pagedListConfig = // final PagedList.Config pagedListConfig =
(new PagedList.Config.Builder()) // (new PagedList.Config.Builder())
.setPrefetchDistance(50) // .setPrefetchDistance(50)
.setPageSize(10).build(); // .setPageSize(10).build();
Factory<Integer, Contribution> factory; // Factory<Integer, Contribution> factory;
factory = repository.fetchContributionsWithStates( // factory = repository.fetchContributionsWithStates(
Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED, // Arrays.asList(Contribution.STATE_IN_PROGRESS, Contribution.STATE_QUEUED,
Contribution.STATE_PAUSED, Contribution.STATE_FAILED)); // Contribution.STATE_PAUSED, Contribution.STATE_FAILED));
//
LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory, // LivePagedListBuilder livePagedListBuilder = new LivePagedListBuilder(factory,
pagedListConfig); // pagedListConfig);
failedAndPendingContributionList = livePagedListBuilder.build(); // failedAndPendingContributionList = livePagedListBuilder.build();
} // }
} }

View file

@ -5,7 +5,6 @@ import static fr.free.nrw.commons.contributions.Contribution.STATE_FAILED;
import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED; import static fr.free.nrw.commons.contributions.Contribution.STATE_PAUSED;
import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL; import static fr.free.nrw.commons.nearby.fragments.NearbyParentFragment.WLM_URL;
import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME; import static fr.free.nrw.commons.profile.ProfileActivity.KEY_USERNAME;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import static fr.free.nrw.commons.utils.LengthUtils.computeBearing; import static fr.free.nrw.commons.utils.LengthUtils.computeBearing;
import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween; import static fr.free.nrw.commons.utils.LengthUtils.formatDistanceBetween;
@ -23,12 +22,10 @@ import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultCallback;
@ -39,7 +36,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager.OnBackStackChangedListener; import androidx.fragment.app.FragmentManager.OnBackStackChangedListener;
import androidx.fragment.app.FragmentTransaction; import androidx.fragment.app.FragmentTransaction;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.Utils; import fr.free.nrw.commons.Utils;
import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.databinding.FragmentContributionsBinding; import fr.free.nrw.commons.databinding.FragmentContributionsBinding;
@ -293,7 +289,7 @@ public class ContributionsFragment
}); });
} }
notification.setOnClickListener(view -> { notification.setOnClickListener(view -> {
NotificationActivity.startYourself(getContext(), "unread"); NotificationActivity.Companion.startYourself(getContext(), "unread");
}); });
} }
@ -307,16 +303,17 @@ public class ContributionsFragment
} }
/** /**
* Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
* Sets the visibility of the upload icon based on the number of failed and pending * Sets the visibility of the upload icon based on the number of failed and pending
* contributions. * contributions.
*/ */
public void setUploadIconVisibility() { // public void setUploadIconVisibility() {
contributionController.getFailedAndPendingContributions(); // contributionController.getFailedAndPendingContributions();
contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(), // contributionController.failedAndPendingContributionList.observe(getViewLifecycleOwner(),
list -> { // list -> {
updateUploadIcon(list.size()); // updateUploadIcon(list.size());
}); // });
} // }
/** /**
* Sets the count for the upload icon based on the number of pending and failed contributions. * Sets the count for the upload icon based on the number of pending and failed contributions.
@ -535,7 +532,8 @@ public class ContributionsFragment
if (!isUserProfile) { if (!isUserProfile) {
setNotificationCount(); setNotificationCount();
fetchCampaigns(); fetchCampaigns();
setUploadIconVisibility(); // Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
// setUploadIconVisibility();
setUploadIconCount(); setUploadIconCount();
} }
} }
@ -570,8 +568,8 @@ public class ContributionsFragment
getString(R.string.nearby_card_permission_explanation), getString(R.string.nearby_card_permission_explanation),
this::requestLocationPermission, this::requestLocationPermission,
this::displayYouWontSeeNearbyMessage, this::displayYouWontSeeNearbyMessage,
checkBoxView, checkBoxView
false); );
} }
private void displayYouWontSeeNearbyMessage() { private void displayYouWontSeeNearbyMessage() {
@ -761,19 +759,18 @@ public class ContributionsFragment
} }
/** /**
* Updates the visibility of the pending uploads ImageView based on the given count. * Temporarily disabled, see issue [https://github.com/commons-app/apps-android-commons/issues/5847]
*
* @param count The number of pending uploads. * @param count The number of pending uploads.
*/ */
public void updateUploadIcon(int count) { // public void updateUploadIcon(int count) {
if (pendingUploadsImageView != null) { // if (pendingUploadsImageView != null) {
if (count != 0) { // if (count != 0) {
pendingUploadsImageView.setVisibility(View.VISIBLE); // pendingUploadsImageView.setVisibility(View.VISIBLE);
} else { // } else {
pendingUploadsImageView.setVisibility(View.GONE); // pendingUploadsImageView.setVisibility(View.GONE);
} // }
} // }
} // }
/** /**
* Replace whatever is in the current contributionsFragmentContainer view with * Replace whatever is in the current contributionsFragmentContainer view with

View file

@ -6,6 +6,7 @@ import static fr.free.nrw.commons.di.NetworkingModule.NAMED_LANGUAGE_WIKI_PEDIA_
import android.Manifest.permission; import android.Manifest.permission;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
@ -20,6 +21,7 @@ import android.widget.LinearLayout;
import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultCallback;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions; import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
@ -96,6 +98,30 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private int contributionsSize; private int contributionsSize;
private String userName; private String userName;
private final ActivityResultLauncher<Intent> galleryPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromGallery(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> customSelectorLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCustomSelector(result, requireActivity(), callbacks);
});
});
private final ActivityResultLauncher<Intent> cameraPickLauncherForResult =
registerForActivityResult(new StartActivityForResult(),
result -> {
controller.handleActivityResultWithCallback(requireActivity(), callbacks -> {
controller.onPictureReturnedFromCamera(result, requireActivity(), callbacks);
});
});
private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult( private ActivityResultLauncher<String[]> inAppCameraLocationPermissionLauncher = registerForActivityResult(
new RequestMultiplePermissions(), new RequestMultiplePermissions(),
new ActivityResultCallback<Map<String, Boolean>>() { new ActivityResultCallback<Map<String, Boolean>>() {
@ -111,7 +137,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
} else { } else {
if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) { if (shouldShowRequestPermissionRationale(permission.ACCESS_FINE_LOCATION)) {
controller.handleShowRationaleFlowCameraLocation(getActivity(), controller.handleShowRationaleFlowCameraLocation(getActivity(),
inAppCameraLocationPermissionLauncher); inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
} else { } else {
controller.locationPermissionCallback.onLocationPermissionDenied( controller.locationPermissionCallback.onLocationPermissionDenied(
getActivity().getString( getActivity().getString(
@ -322,7 +348,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
private void setListeners() { private void setListeners() {
binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen)); binding.fabPlus.setOnClickListener(view -> animateFAB(isFabOpen));
binding.fabCamera.setOnClickListener(view -> { binding.fabCamera.setOnClickListener(view -> {
controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher); controller.initiateCameraPick(getActivity(), inAppCameraLocationPermissionLauncher, cameraPickLauncherForResult);
animateFAB(isFabOpen); animateFAB(isFabOpen);
}); });
binding.fabCamera.setOnLongClickListener(view -> { binding.fabCamera.setOnLongClickListener(view -> {
@ -330,7 +356,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
return true; return true;
}); });
binding.fabGallery.setOnClickListener(view -> { binding.fabGallery.setOnClickListener(view -> {
controller.initiateGalleryPick(getActivity(), true); controller.initiateGalleryPick(getActivity(), galleryPickLauncherForResult, true);
animateFAB(isFabOpen); animateFAB(isFabOpen);
}); });
binding.fabGallery.setOnLongClickListener(view -> { binding.fabGallery.setOnLongClickListener(view -> {
@ -343,7 +369,7 @@ public class ContributionsListFragment extends CommonsDaggerSupportFragment impl
* Launch Custom Selector. * Launch Custom Selector.
*/ */
protected void launchCustomSelector() { protected void launchCustomSelector() {
controller.initiateCustomGalleryPickWithPermission(getActivity()); controller.initiateCustomGalleryPickWithPermission(getActivity(), customSelectorLauncherForResult);
animateFAB(isFabOpen); animateFAB(isFabOpen);
} }

View file

@ -1,5 +1,7 @@
package fr.free.nrw.commons.contributions; package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.paging.DataSource; import androidx.paging.DataSource;
@ -34,7 +36,7 @@ public class ContributionsListPresenter implements UserActionListener {
final ContributionBoundaryCallback contributionBoundaryCallback, final ContributionBoundaryCallback contributionBoundaryCallback,
final ContributionsRemoteDataSource contributionsRemoteDataSource, final ContributionsRemoteDataSource contributionsRemoteDataSource,
final ContributionsRepository repository, final ContributionsRepository repository,
@Named(CommonsApplicationModule.IO_THREAD) final Scheduler ioThreadScheduler) { @Named(IO_THREAD) final Scheduler ioThreadScheduler) {
this.contributionBoundaryCallback = contributionBoundaryCallback; this.contributionBoundaryCallback = contributionBoundaryCallback;
this.repository = repository; this.repository = repository;
this.ioThreadScheduler = ioThreadScheduler; this.ioThreadScheduler = ioThreadScheduler;

View file

@ -1,5 +1,6 @@
package fr.free.nrw.commons.contributions; package fr.free.nrw.commons.contributions;
import static fr.free.nrw.commons.di.CommonsApplicationModule.IO_THREAD;
import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK; import static fr.free.nrw.commons.utils.ImageUtils.IMAGE_OK;
import androidx.work.ExistingWorkPolicy; import androidx.work.ExistingWorkPolicy;
@ -31,7 +32,7 @@ public class ContributionsPresenter implements UserActionListener {
@Inject @Inject
ContributionsPresenter(ContributionsRepository repository, ContributionsPresenter(ContributionsRepository repository,
UploadRepository uploadRepository, UploadRepository uploadRepository,
@Named(CommonsApplicationModule.IO_THREAD) Scheduler ioThreadScheduler) { @Named(IO_THREAD) Scheduler ioThreadScheduler) {
this.contributionsRepository = repository; this.contributionsRepository = repository;
this.uploadRepository = uploadRepository; this.uploadRepository = uploadRepository;
this.ioThreadScheduler = ioThreadScheduler; this.ioThreadScheduler = ioThreadScheduler;

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.contributions package fr.free.nrw.commons.contributions
import androidx.paging.ItemKeyedDataSource import androidx.paging.ItemKeyedDataSource
import fr.free.nrw.commons.di.CommonsApplicationModule import fr.free.nrw.commons.di.CommonsApplicationModule.Companion.IO_THREAD
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import io.reactivex.Scheduler import io.reactivex.Scheduler
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
@ -16,7 +16,7 @@ class ContributionsRemoteDataSource
@Inject @Inject
constructor( constructor(
private val mediaClient: MediaClient, private val mediaClient: MediaClient,
@param:Named(CommonsApplicationModule.IO_THREAD) private val ioThreadScheduler: Scheduler, @param:Named(IO_THREAD) private val ioThreadScheduler: Scheduler,
) : ItemKeyedDataSource<Int, Contribution>() { ) : ItemKeyedDataSource<Int, Contribution>() {
private val compositeDisposable: CompositeDisposable = CompositeDisposable() private val compositeDisposable: CompositeDisposable = CompositeDisposable()
var userName: String? = null var userName: String? = null

View file

@ -1,13 +1,10 @@
package fr.free.nrw.commons.contributions; package fr.free.nrw.commons.contributions;
import android.Manifest.permission;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle; import android.os.Bundle;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
@ -16,10 +13,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.viewpager.widget.ViewPager;
import androidx.work.ExistingWorkPolicy; import androidx.work.ExistingWorkPolicy;
import fr.free.nrw.commons.databinding.MainBinding; import fr.free.nrw.commons.databinding.MainBinding;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.R; import fr.free.nrw.commons.R;
import fr.free.nrw.commons.WelcomeActivity; import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.auth.SessionManager;
@ -41,7 +36,6 @@ import fr.free.nrw.commons.notification.NotificationController;
import fr.free.nrw.commons.quiz.QuizChecker; import fr.free.nrw.commons.quiz.QuizChecker;
import fr.free.nrw.commons.settings.SettingsFragment; import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.theme.BaseActivity; import fr.free.nrw.commons.theme.BaseActivity;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity; import fr.free.nrw.commons.upload.UploadProgressActivity;
import fr.free.nrw.commons.upload.worker.WorkRequestHelper; import fr.free.nrw.commons.upload.worker.WorkRequestHelper;
import fr.free.nrw.commons.utils.PermissionUtils; import fr.free.nrw.commons.utils.PermissionUtils;
@ -420,7 +414,7 @@ public class MainActivity extends BaseActivity
return true; return true;
case R.id.notifications: case R.id.notifications:
// Starts notification activity on click to notification icon // Starts notification activity on click to notification icon
NotificationActivity.startYourself(this, "unread"); NotificationActivity.Companion.startYourself(this, "unread");
return true; return true;
default: default:
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
@ -438,13 +432,6 @@ public class MainActivity extends BaseActivity
}); });
} }
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
Timber.d(data != null ? data.toString() : "onActivityResult data is null");
super.onActivityResult(requestCode, resultCode, data);
controller.handleActivityResult(this, requestCode, resultCode, data);
}
@Override @Override
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();

View file

@ -22,7 +22,7 @@ class WikipediaInstructionsDialogFragment : DialogFragment() {
) = DialogAddToWikipediaInstructionsBinding ) = DialogAddToWikipediaInstructionsBinding
.inflate(inflater, container, false) .inflate(inflater, container, false)
.apply { .apply {
val contribution: Contribution? = arguments!!.getParcelable(ARG_CONTRIBUTION) val contribution: Contribution? = requireArguments().getParcelable(ARG_CONTRIBUTION)
tvWikicode.setText(contribution?.media?.wikiCode) tvWikicode.setText(contribution?.media?.wikiCode)
instructionsCancel.setOnClickListener { dismiss() } instructionsCancel.setOnClickListener { dismiss() }
instructionsConfirm.setOnClickListener { instructionsConfirm.setOnClickListener {

View file

@ -1,187 +0,0 @@
package fr.free.nrw.commons.coordinates;
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_COORDINATES;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.schedulers.Schedulers;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import org.apache.commons.lang3.StringUtils;
import timber.log.Timber;
/**
* Helper class for edit and update given coordinates and showing notification about new coordinates
* upgradation
*/
public class CoordinateEditHelper {
/**
* notificationHelper: helps creating notification
*/
private final NotificationHelper notificationHelper;
/**
* * pageEditClient: methods provided by this member posts the edited coordinates
* to the Media wiki api
*/
public final PageEditClient pageEditClient;
/**
* viewUtil: helps to show Toast
*/
private final ViewUtilWrapper viewUtil;
@Inject
public CoordinateEditHelper(final NotificationHelper notificationHelper,
@Named("commons-page-edit") final PageEditClient pageEditClient,
final ViewUtilWrapper viewUtil) {
this.notificationHelper = notificationHelper;
this.pageEditClient = pageEditClient;
this.viewUtil = viewUtil;
}
/**
* Public interface to edit coordinates
* @param context to be added
* @param media to be added
* @param Accuracy to be added
* @return Single<Boolean>
*/
public Single<Boolean> makeCoordinatesEdit(final Context context, final Media media,
final String Latitude, final String Longitude, final String Accuracy) {
viewUtil.showShortToast(context,
context.getString(R.string.coordinates_edit_helper_make_edit_toast));
return addCoordinates(media, Latitude, Longitude, Accuracy)
.flatMapSingle(result -> Single.just(showCoordinatesEditNotification(context, media,
Latitude, Longitude, Accuracy, result)))
.firstOrError();
}
/**
* Replaces new coordinates
* @param media to be added
* @param Latitude to be added
* @param Longitude to be added
* @param Accuracy to be added
* @return Observable<Boolean>
*/
private Observable<Boolean> addCoordinates(final Media media, final String Latitude,
final String Longitude, final String Accuracy) {
Timber.d("thread is coordinates adding %s", Thread.currentThread().getName());
final String summary = "Adding Coordinates";
final StringBuilder buffer = new StringBuilder();
final String wikiText = pageEditClient.getCurrentWikiText(media.getFilename())
.subscribeOn(Schedulers.io())
.blockingGet();
if (Latitude != null) {
buffer.append("\n{{Location|").append(Latitude).append("|").append(Longitude)
.append("|").append(Accuracy).append("}}");
}
final String editedLocation = buffer.toString();
final String appendText = getFormattedWikiText(wikiText, editedLocation);
return pageEditClient.edit(Objects.requireNonNull(media.getFilename())
, appendText, summary);
}
/**
* Helps to get formatted wikitext with upgraded location
* @param wikiText current wikitext
* @param editedLocation new location
* @return String
*/
private String getFormattedWikiText(final String wikiText, final String editedLocation){
if (wikiText.contains("filedesc") && wikiText.contains("Location")) {
final String fromLocationToEnd = wikiText.substring(wikiText.indexOf("{{Location"));
final String firstHalf = wikiText.substring(0, wikiText.indexOf("{{Location"));
final String lastHalf = fromLocationToEnd.substring(
fromLocationToEnd.indexOf("}}") + 2);
final int startOfSecondSection = StringUtils.ordinalIndexOf(wikiText,
"==", 3);
final StringBuilder buffer = new StringBuilder();
if (wikiText.charAt(wikiText.indexOf("{{Location")-1) == '\n') {
buffer.append(editedLocation.substring(1));
} else {
buffer.append(editedLocation);
}
if (startOfSecondSection != -1 && wikiText.charAt(startOfSecondSection-1)!= '\n') {
buffer.append("\n");
}
return firstHalf + buffer + lastHalf;
}
if (wikiText.contains("filedesc") && !wikiText.contains("Location")) {
final int startOfSecondSection = StringUtils.ordinalIndexOf(wikiText,
"==", 3);
if (startOfSecondSection != -1) {
final String firstHalf = wikiText.substring(0, startOfSecondSection);
final String lastHalf = wikiText.substring(startOfSecondSection);
final String buffer = editedLocation.substring(1)
+ "\n";
return firstHalf + buffer + lastHalf;
}
return wikiText + editedLocation;
}
return "== {{int:filedesc}} ==" + editedLocation + wikiText;
}
/**
* Update coordinates and shows notification about coordinates update
* @param context to be added
* @param media to be added
* @param latitude to be added
* @param longitude to be added
* @param Accuracy to be added
* @param result to be added
* @return boolean
*/
private boolean showCoordinatesEditNotification(final Context context, final Media media,
final String latitude, final String longitude, final String Accuracy,
final boolean result) {
final String message;
String title = context.getString(R.string.coordinates_edit_helper_show_edit_title);
if (result) {
media.setCoordinates(
new fr.free.nrw.commons.location.LatLng(Double.parseDouble(latitude),
Double.parseDouble(longitude),
Float.parseFloat(Accuracy)));
title += ": " + context
.getString(R.string.coordinates_edit_helper_show_edit_title_success);
final StringBuilder coordinatesInMessage = new StringBuilder();
final String mediaCoordinate = String.valueOf(media.getCoordinates());
coordinatesInMessage.append(mediaCoordinate);
message = context.getString(R.string.coordinates_edit_helper_show_edit_message,
coordinatesInMessage.toString());
} else {
title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title);
message = context.getString(R.string.coordinates_edit_helper_edit_message_else) ;
}
final String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_COORDINATES,
browserIntent);
return result;
}
}

View file

@ -0,0 +1,189 @@
package fr.free.nrw.commons.coordinates
import android.content.Context
import android.content.Intent
import android.net.Uri
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.actions.PageEditClient
import fr.free.nrw.commons.notification.NotificationHelper
import fr.free.nrw.commons.notification.NotificationHelper.Companion.NOTIFICATION_EDIT_COORDINATES
import fr.free.nrw.commons.utils.ViewUtilWrapper
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.util.Objects
import javax.inject.Inject
import javax.inject.Named
import org.apache.commons.lang3.StringUtils
import timber.log.Timber
/**
* Helper class for edit and update given coordinates and showing notification about new coordinates
* upgradation
*/
class CoordinateEditHelper @Inject constructor(
private val notificationHelper: NotificationHelper,
@Named("commons-page-edit") private val pageEditClient: PageEditClient,
private val viewUtil: ViewUtilWrapper
) {
/**
* Public interface to edit coordinates
* @param context to be added
* @param media to be added
* @param latitude to be added
* @param longitude to be added
* @param accuracy to be added
* @return Single<Boolean>
*/
fun makeCoordinatesEdit(
context: Context,
media: Media,
latitude: String,
longitude: String,
accuracy: String
): Single<Boolean>? {
viewUtil.showShortToast(
context,
context.getString(R.string.coordinates_edit_helper_make_edit_toast)
)
return addCoordinates(media, latitude, longitude, accuracy)
?.flatMapSingle { result ->
Single.just(showCoordinatesEditNotification(context, media, latitude, longitude, accuracy, result))
}
?.firstOrError()
}
/**
* Replaces new coordinates
* @param media to be added
* @param Latitude to be added
* @param Longitude to be added
* @param Accuracy to be added
* @return Observable<Boolean>
*/
private fun addCoordinates(
media: Media,
Latitude: String,
Longitude: String,
Accuracy: String
): Observable<Boolean>? {
Timber.d("thread is coordinates adding %s", Thread.currentThread().getName())
val summary = "Adding Coordinates"
val buffer = StringBuilder()
val wikiText = media.filename?.let {
pageEditClient.getCurrentWikiText(it)
.subscribeOn(Schedulers.io())
.blockingGet()
}
if (Latitude != null) {
buffer.append("\n{{Location|").append(Latitude).append("|").append(Longitude)
.append("|").append(Accuracy).append("}}")
}
val editedLocation = buffer.toString()
val appendText = wikiText?.let { getFormattedWikiText(it, editedLocation) }
return Objects.requireNonNull(media.filename)
?.let { pageEditClient.edit(it, appendText!!, summary) }
}
/**
* Helps to get formatted wikitext with upgraded location
* @param wikiText current wikitext
* @param editedLocation new location
* @return String
*/
private fun getFormattedWikiText(wikiText: String, editedLocation: String): String {
if (wikiText.contains("filedesc") && wikiText.contains("Location")) {
val fromLocationToEnd = wikiText.substring(wikiText.indexOf("{{Location"))
val firstHalf = wikiText.substring(0, wikiText.indexOf("{{Location"))
val lastHalf = fromLocationToEnd.substring(fromLocationToEnd.indexOf("}}") + 2)
val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3)
val buffer = StringBuilder()
if (wikiText[wikiText.indexOf("{{Location") - 1] == '\n') {
buffer.append(editedLocation.substring(1))
} else {
buffer.append(editedLocation)
}
if (startOfSecondSection != -1 && wikiText[startOfSecondSection - 1] != '\n') {
buffer.append("\n")
}
return firstHalf + buffer + lastHalf
}
if (wikiText.contains("filedesc") && !wikiText.contains("Location")) {
val startOfSecondSection = StringUtils.ordinalIndexOf(wikiText, "==", 3)
if (startOfSecondSection != -1) {
val firstHalf = wikiText.substring(0, startOfSecondSection)
val lastHalf = wikiText.substring(startOfSecondSection)
val buffer = editedLocation.substring(1) + "\n"
return firstHalf + buffer + lastHalf
}
return wikiText + editedLocation
}
return "== {{int:filedesc}} ==$editedLocation$wikiText"
}
/**
* Update coordinates and shows notification about coordinates update
* @param context to be added
* @param media to be added
* @param latitude to be added
* @param longitude to be added
* @param Accuracy to be added
* @param result to be added
* @return boolean
*/
private fun showCoordinatesEditNotification(
context: Context,
media: Media,
latitude: String,
longitude: String,
Accuracy: String,
result: Boolean
): Boolean {
val message: String
var title = context.getString(R.string.coordinates_edit_helper_show_edit_title)
if (result) {
media.coordinates = fr.free.nrw.commons.location.LatLng(
latitude.toDouble(),
longitude.toDouble(),
Accuracy.toFloat()
)
title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title_success)
val coordinatesInMessage = StringBuilder()
val mediaCoordinate = media.coordinates.toString()
coordinatesInMessage.append(mediaCoordinate)
message = context.getString(
R.string.coordinates_edit_helper_show_edit_message,
coordinatesInMessage.toString()
)
} else {
title += ": " + context.getString(R.string.coordinates_edit_helper_show_edit_title)
message = context.getString(R.string.coordinates_edit_helper_edit_message_else)
}
val urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.filename
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
notificationHelper.showNotification(
context,
title,
message,
NOTIFICATION_EDIT_COORDINATES,
browserIntent
)
return result
}
}

View file

@ -0,0 +1,248 @@
package fr.free.nrw.commons.customselector.helper
import android.content.ContentUris
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.appcompat.app.AlertDialog
import fr.free.nrw.commons.R
import timber.log.Timber
import java.io.File
object FolderDeletionHelper {
/**
* Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it.
*
* @param context The context used to show the confirmation dialog and manage deletion.
* @param folder The folder to be deleted.
* @param onDeletionComplete Callback invoked with `true` if the folder was
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
* successfully deleted, `false` otherwise.
*/
fun confirmAndDeleteFolder(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>,
onDeletionComplete: (Boolean) -> Unit) {
//don't show this dialog on API 30+, it's handled automatically using MediaStore
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val success = trashImagesInFolder(context, folder, trashFolderLauncher)
onDeletionComplete(success)
} else {
val imagePaths = listImagesInFolder(context, folder)
val imageCount = imagePaths.size
val folderPath = folder.absolutePath
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
.setMessage(
context.getString(
R.string.custom_selector_confirm_deletion_message,
folderPath,
imageCount
)
)
.setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
//proceed with deletion if user confirms
val success = deleteImagesLegacy(imagePaths)
onDeletionComplete(success)
}
.setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ ->
dialog.dismiss()
onDeletionComplete(false)
}
.show()
}
}
/**
* Moves all images in a specified folder (but not within its subfolders) to the trash on
* devices running Android 11 (API level 30) and above.
*
* @param context The context used to access the content resolver.
* @param folder The folder whose top-level images are to be moved to the trash.
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash
* request.
* @return `true` if the trash request was initiated successfully, `false` otherwise.
*/
private fun trashImagesInFolder(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
val contentResolver = context.contentResolver
val folderPath = folder.absolutePath
val urisToTrash = mutableListOf<Uri>()
val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
// select images contained in the folder but not within subfolders
val selection =
"${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
contentResolver.query(
mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
selectionArgs, null
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val fileUri = ContentUris.withAppendedId(mediaUri, id)
urisToTrash.add(fileUri)
}
}
//proceed with trashing if we have valid URIs
if (urisToTrash.isNotEmpty()) {
try {
val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true)
val intentSenderRequest =
IntentSenderRequest.Builder(trashRequest.intentSender).build()
trashFolderLauncher.launch(intentSenderRequest)
return true
} catch (e: SecurityException) {
Timber.tag("DeleteFolder").e(
context.getString(
R.string.custom_selector_error_trashing_folder_contents,
e.message
)
)
}
}
return false
}
/**
* Lists all image file paths in the specified folder, excluding any subfolders.
*
* @param context The context used to access the content resolver.
* @param folder The folder whose top-level images are to be listed.
* @return A list of file paths (as Strings) pointing to the images in the specified folder.
*/
private fun listImagesInFolder(context: Context, folder: File): List<String> {
val contentResolver = context.contentResolver
val folderPath = folder.absolutePath
val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection =
"${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
val imagePaths = mutableListOf<String>()
contentResolver.query(
mediaUri, arrayOf(MediaStore.MediaColumns.DATA), selection,
selectionArgs, null
)?.use { cursor ->
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
while (cursor.moveToNext()) {
val imagePath = cursor.getString(dataColumn)
imagePaths.add(imagePath)
}
}
return imagePaths
}
/**
* Refreshes the MediaStore for a specified folder, updating the system to recognize any changes
*
* @param context The context used to access the MediaScannerConnection.
* @param folder The folder to refresh in the MediaStore.
*/
fun refreshMediaStore(context: Context, folder: File) {
MediaScannerConnection.scanFile(
context,
arrayOf(folder.absolutePath),
null
) { _, _ -> }
}
/**
* Deletes a list of image files specified by their paths, on
* Android 10 (API level 29) and below.
*
* @param imagePaths A list of absolute file paths to image files that need to be deleted.
* @return `true` if all the images are successfully deleted, `false` otherwise.
*/
private fun deleteImagesLegacy(imagePaths: List<String>): Boolean {
var result = true
imagePaths.forEach {
val imageFile = File(it)
val deleted = imageFile.exists() && imageFile.delete()
result = result && deleted
}
return result
}
/**
* Retrieves the absolute path of a folder given its unique identifier (bucket ID).
*
* @param context The context used to access the content resolver.
* @param folderId The unique identifier (bucket ID) of the folder.
* @return The absolute path of the folder as a `String`, or `null` if the folder is not found.
*/
fun getFolderPath(context: Context, folderId: Long): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?"
val selectionArgs = arrayOf(folderId.toString())
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
return File(fullPath).parent
}
}
return null
}
/**
* Displays an error message to the user and logs it for debugging purposes.
*
* @param context The context used to display the Toast.
* @param message The error message to display and log.
* @param folderName The name of the folder to delete.
*/
fun showError(context: Context, message: String, folderName: String) {
Toast.makeText(context,
context.getString(R.string.custom_selector_folder_deleted_failure, folderName),
Toast.LENGTH_SHORT).show()
Timber.tag("DeleteFolder").e(message)
}
/**
* Displays a success message to the user.
*
* @param context The context used to display the Toast.
* @param message The success message to display.
* @param folderName The name of the folder to delete.
*/
fun showSuccess(context: Context, message: String, folderName: String) {
Toast.makeText(context,
context.getString(R.string.custom_selector_folder_deleted_success, folderName),
Toast.LENGTH_SHORT).show()
Timber.tag("DeleteFolder").d(message)
}
}

View file

@ -12,7 +12,11 @@ import android.view.View
import android.view.Window import android.view.Window
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -41,14 +45,13 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding
import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding
import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding
import fr.free.nrw.commons.filepicker.Constants
import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
@ -147,6 +150,23 @@ class CustomSelectorActivity :
private var showPartialAccessIndicator by mutableStateOf(false) private var showPartialAccessIndicator by mutableStateOf(false)
/**
* Show delete button in folder
*/
private var showOverflowMenu = false
/**
* Waits for confirmation of delete folder
*/
private val startForFolderDeletionResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()){
result -> onDeleteFolderResultReceived(result)
}
private val startForResult = registerForActivityResult(StartActivityForResult()){ result ->
onFullScreenDataReceived(result)
}
/** /**
* onCreate Activity, sets theme, initialises the view model, setup view. * onCreate Activity, sets theme, initialises the view model, setup view.
*/ */
@ -225,23 +245,24 @@ class CustomSelectorActivity :
/** /**
* When data will be send from full screen mode, it will be passed to fragment * When data will be send from full screen mode, it will be passed to fragment
*/ */
override fun onActivityResult( private fun onFullScreenDataReceived(result: ActivityResult){
requestCode: Int, if (result.resultCode == Activity.RESULT_OK) {
resultCode: Int,
data: Intent?,
) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE &&
resultCode == Activity.RESULT_OK
) {
val selectedImages: ArrayList<Image> = val selectedImages: ArrayList<Image> =
data!! result.data!!
.getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!! .getParcelableArrayListExtra(CustomSelectorConstants.NEW_SELECTED_IMAGES)!!
val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false) viewModel?.selectedImages?.value = selectedImages
imageFragment?.passSelectedImages(selectedImages, shouldRefresh)
} }
} }
private fun onDeleteFolderResultReceived(result: ActivityResult){
if (result.resultCode == Activity.RESULT_OK){
FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
navigateToCustomSelector()
}
}
/** /**
* Show Custom Selector Welcome Dialog. * Show Custom Selector Welcome Dialog.
*/ */
@ -423,10 +444,97 @@ class CustomSelectorActivity :
val limitError: ImageButton = findViewById(R.id.image_limit_error) val limitError: ImageButton = findViewById(R.id.image_limit_error)
limitError.visibility = View.INVISIBLE limitError.visibility = View.INVISIBLE
limitError.setOnClickListener { displayUploadLimitWarning() } limitError.setOnClickListener { displayUploadLimitWarning() }
val overflowMenu: ImageButton = findViewById(R.id.menu_overflow)
if(defaultKvStore.getBoolean("displayDeletionButton")) {
overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE
overflowMenu.setOnClickListener { showPopupMenu(overflowMenu) }
}else{
overflowMenu.visibility = View.GONE
}
}
private fun showPopupMenu(anchorView: View) {
val popupMenu = PopupMenu(this, anchorView)
popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_delete_folder -> {
deleteFolder()
true
}
else -> false
}
}
popupMenu.show()
} }
/** /**
* override on folder click, change the toolbar title on folder click. * Deletes folder based on Android API version.
*/
private fun deleteFolder() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: run {
FolderDeletionHelper.showError(this, "Failed to retrieve folder path", bucketName)
return
}
val folder = File(folderPath)
if (!folder.exists() || !folder.isDirectory) {
FolderDeletionHelper.showError(this,"Folder not found or is not a directory", bucketName)
return
}
FolderDeletionHelper.confirmAndDeleteFolder(this, folder, startForFolderDeletionResult) { success ->
if (success) {
//for API 30+, navigation is handled in 'onDeleteFolderResultReceived'
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
navigateToCustomSelector()
}
} else {
FolderDeletionHelper.showError(this, "Failed to delete folder", bucketName)
}
}
}
/**
* Navigates back to the main `FolderFragment`, refreshes the MediaStore, resets UI states,
* and reloads folder data.
*/
private fun navigateToCustomSelector() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: ""
val folder = File(folderPath)
supportFragmentManager.popBackStack(null,
androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE)
//refresh MediaStore for the deleted folder path to ensure metadata updates
FolderDeletionHelper.refreshMediaStore(this, folder)
//replace the current fragment with FolderFragment to go back to the main screen
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance())
.commitAllowingStateLoss()
//reset toolbar and flags
isImageFragmentOpen = false
showOverflowMenu = false
setUpToolbar()
changeTitle(getString(R.string.custom_selector_title), 0)
//fetch updated folder data
fetchData()
}
/**
* override on folder click,
* change the toolbar title on folder click, make overflow menu visible
*/ */
override fun onFolderClick( override fun onFolderClick(
folderId: Long, folderId: Long,
@ -444,6 +552,11 @@ class CustomSelectorActivity :
bucketId = folderId bucketId = folderId
bucketName = folderName bucketName = folderName
isImageFragmentOpen = true isImageFragmentOpen = true
//show the overflow menu only when a folder is clicked
showOverflowMenu = true
setUpToolbar()
} }
/** /**
@ -511,7 +624,7 @@ class CustomSelectorActivity :
selectedImages, selectedImages,
) )
intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId) intent.putExtra(CustomSelectorConstants.BUCKET_ID, bucketId)
startActivityForResult(intent, Constants.RequestCodes.RECEIVE_DATA_FROM_FULL_SCREEN_MODE) startForResult.launch(intent)
} }
/** /**
@ -559,6 +672,10 @@ class CustomSelectorActivity :
isImageFragmentOpen = false isImageFragmentOpen = false
changeTitle(getString(R.string.custom_selector_title), 0) changeTitle(getString(R.string.custom_selector_title), 0)
} }
//hide overflow menu when not in folder
showOverflowMenu = false
setUpToolbar()
} }
/** /**

View file

@ -1,6 +1,7 @@
package fr.free.nrw.commons.customselector.ui.selector package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
@ -279,13 +280,19 @@ class ImageFragment :
filteredImages = ImageHelper.filterImages(images, bucketId) filteredImages = ImageHelper.filterImages(images, bucketId)
allImages = ArrayList(filteredImages) allImages = ArrayList(filteredImages)
imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions) imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions)
viewModel?.selectedImages?.value?.let { selectedImages ->
imageAdapter.setSelectedImages(selectedImages)
}
imageAdapter.notifyDataSetChanged()
selectorRV?.let { selectorRV?.let {
it.visibility = View.VISIBLE it.visibility = View.VISIBLE
if (switch?.isChecked == false) {
lastItemId?.let { pos -> lastItemId?.let { pos ->
(it.layoutManager as GridLayoutManager) (it.layoutManager as GridLayoutManager)
.scrollToPosition(ImageHelper.getIndexFromId(filteredImages, pos)) .scrollToPosition(ImageHelper.getIndexFromId(filteredImages, pos))
} }
} }
}
} else { } else {
filteredImages = ArrayList() filteredImages = ArrayList()
allImages = filteredImages allImages = filteredImages
@ -340,7 +347,7 @@ class ImageFragment :
context context
.getSharedPreferences( .getSharedPreferences(
"CustomSelector", "CustomSelector",
BaseActivity.MODE_PRIVATE, MODE_PRIVATE,
)?.let { prefs -> )?.let { prefs ->
prefs.edit()?.let { editor -> prefs.edit()?.let { editor ->
editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply() editor.putLong("ItemId", imageAdapter.getImageIdAt(position))?.apply()
@ -382,14 +389,6 @@ class ImageFragment :
selectedImages: ArrayList<Image>, selectedImages: ArrayList<Image>,
shouldRefresh: Boolean, shouldRefresh: Boolean,
) { ) {
imageAdapter.setSelectedImages(selectedImages)
val uploadingContributions = getUploadingContributions()
if (!showAlreadyActionedImages && shouldRefresh) {
imageAdapter.init(filteredImages, allImages, TreeMap(), uploadingContributions)
imageAdapter.setSelectedImages(selectedImages)
}
} }
/** /**

View file

@ -17,6 +17,7 @@ import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1 import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar import java.util.Calendar

View file

@ -1,63 +0,0 @@
package fr.free.nrw.commons.data;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao;
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao;
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao;
import fr.free.nrw.commons.category.CategoryDao;
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao;
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao;
public class DBOpenHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "commons.db";
private static final int DATABASE_VERSION = 20;
public static final String CONTRIBUTIONS_TABLE = "contributions";
private final String DROP_TABLE_STATEMENT="DROP TABLE IF EXISTS %s";
/**
* Do not use directly - @Inject an instance where it's needed and let
* dependency injection take care of managing this as a singleton.
*/
public DBOpenHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
CategoryDao.Table.onCreate(sqLiteDatabase);
BookmarkPicturesDao.Table.onCreate(sqLiteDatabase);
BookmarkLocationsDao.Table.onCreate(sqLiteDatabase);
BookmarkItemsDao.Table.onCreate(sqLiteDatabase);
RecentSearchesDao.Table.onCreate(sqLiteDatabase);
RecentLanguagesDao.Table.onCreate(sqLiteDatabase);
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) {
CategoryDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkPicturesDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkLocationsDao.Table.onUpdate(sqLiteDatabase, from, to);
BookmarkItemsDao.Table.onUpdate(sqLiteDatabase, from, to);
RecentSearchesDao.Table.onUpdate(sqLiteDatabase, from, to);
RecentLanguagesDao.Table.onUpdate(sqLiteDatabase, from, to);
deleteTable(sqLiteDatabase,CONTRIBUTIONS_TABLE);
}
/**
* Delete table in the given db
* @param db
* @param tableName
*/
public void deleteTable(SQLiteDatabase db, String tableName) {
try {
db.execSQL(String.format(DROP_TABLE_STATEMENT, tableName));
onCreate(db);
} catch (SQLiteException e) {
e.printStackTrace();
}
}
}

View file

@ -0,0 +1,62 @@
package fr.free.nrw.commons.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteOpenHelper
import fr.free.nrw.commons.bookmarks.items.BookmarkItemsDao
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.bookmarks.pictures.BookmarkPicturesDao
import fr.free.nrw.commons.category.CategoryDao
import fr.free.nrw.commons.explore.recentsearches.RecentSearchesDao
import fr.free.nrw.commons.recentlanguages.RecentLanguagesDao
class DBOpenHelper(
context: Context
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
private const val DATABASE_NAME = "commons.db"
private const val DATABASE_VERSION = 20
const val CONTRIBUTIONS_TABLE = "contributions"
private const val DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS %s"
}
/**
* Do not use directly - @Inject an instance where it's needed and let
* dependency injection take care of managing this as a singleton.
*/
override fun onCreate(db: SQLiteDatabase) {
CategoryDao.Table.onCreate(db)
BookmarkPicturesDao.Table.onCreate(db)
BookmarkLocationsDao.Table.onCreate(db)
BookmarkItemsDao.Table.onCreate(db)
RecentSearchesDao.Table.onCreate(db)
RecentLanguagesDao.Table.onCreate(db)
}
override fun onUpgrade(db: SQLiteDatabase, from: Int, to: Int) {
CategoryDao.Table.onUpdate(db, from, to)
BookmarkPicturesDao.Table.onUpdate(db, from, to)
BookmarkLocationsDao.Table.onUpdate(db, from, to)
BookmarkItemsDao.Table.onUpdate(db, from, to)
RecentSearchesDao.Table.onUpdate(db, from, to)
RecentLanguagesDao.Table.onUpdate(db, from, to)
deleteTable(db, CONTRIBUTIONS_TABLE)
}
/**
* Delete table in the given db
* @param db
* @param tableName
*/
fun deleteTable(db: SQLiteDatabase, tableName: String) {
try {
db.execSQL(String.format(DROP_TABLE_STATEMENT, tableName))
onCreate(db)
} catch (e: SQLiteException) {
e.printStackTrace()
}
}
}

View file

@ -1,162 +0,0 @@
package fr.free.nrw.commons.db;
import android.net.Uri;
import androidx.room.TypeConverter;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.contributions.ChunkInfo;
import fr.free.nrw.commons.di.ApplicationlessInjection;
import fr.free.nrw.commons.location.LatLng;
import fr.free.nrw.commons.nearby.Sitelinks;
import fr.free.nrw.commons.upload.WikidataPlace;
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem;
import java.lang.reflect.Type;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* This class supplies converters to write/read types to/from the database.
*/
public class Converters {
public static Gson getGson() {
return ApplicationlessInjection.getInstance(CommonsApplication.getInstance()).getCommonsApplicationComponent().gson();
}
/**
* convert DepictedItem object to string
* input Example -> DepictedItem depictedItem=new DepictedItem ()
* output Example -> string
*/
@TypeConverter
public static String depictsItemToString(DepictedItem objects) {
return writeObjectToString(objects);
}
/**
* convert string to DepictedItem object
* output Example -> DepictedItem depictedItem=new DepictedItem ()
* input Example -> string
*/
@TypeConverter
public static DepictedItem stringToDepicts(String objectList) {
return readObjectWithTypeToken(objectList, new TypeToken<DepictedItem>() {
});
}
@TypeConverter
public static Date fromTimestamp(Long value) {
return value == null ? null : new Date(value);
}
@TypeConverter
public static Long dateToTimestamp(Date date) {
return date == null ? null : date.getTime();
}
@TypeConverter
public static Uri fromString(String value) {
return value == null ? null : Uri.parse(value);
}
@TypeConverter
public static String uriToString(Uri uri) {
return uri == null ? null : uri.toString();
}
@TypeConverter
public static String listObjectToString(List<String> objectList) {
return writeObjectToString(objectList);
}
@TypeConverter
public static List<String> stringToListObject(String objectList) {
return readObjectWithTypeToken(objectList, new TypeToken<List<String>>() {});
}
@TypeConverter
public static String mapObjectToString(Map<String,String> objectList) {
return writeObjectToString(objectList);
}
@TypeConverter
public static String mapObjectToString2(Map<String,Boolean> objectList) {
return writeObjectToString(objectList);
}
@TypeConverter
public static Map<String,String> stringToMap(String objectList) {
return readObjectWithTypeToken(objectList, new TypeToken<Map<String,String>>(){});
}
@TypeConverter
public static Map<String,Boolean> stringToMap2(String objectList) {
return readObjectWithTypeToken(objectList, new TypeToken<Map<String,Boolean>>(){});
}
@TypeConverter
public static String latlngObjectToString(LatLng latlng) {
return writeObjectToString(latlng);
}
@TypeConverter
public static LatLng stringToLatLng(String objectList) {
return readObjectFromString(objectList,LatLng.class);
}
@TypeConverter
public static String wikidataPlaceToString(WikidataPlace wikidataPlace) {
return writeObjectToString(wikidataPlace);
}
@TypeConverter
public static WikidataPlace stringToWikidataPlace(String wikidataPlace) {
return readObjectFromString(wikidataPlace, WikidataPlace.class);
}
@TypeConverter
public static String chunkInfoToString(ChunkInfo chunkInfo) {
return writeObjectToString(chunkInfo);
}
@TypeConverter
public static ChunkInfo stringToChunkInfo(String chunkInfo) {
return readObjectFromString(chunkInfo, ChunkInfo.class);
}
@TypeConverter
public static String depictionListToString(List<DepictedItem> depictedItems) {
return writeObjectToString(depictedItems);
}
@TypeConverter
public static List<DepictedItem> stringToList(String depictedItems) {
return readObjectWithTypeToken(depictedItems, new TypeToken<List<DepictedItem>>() {});
}
@TypeConverter
public static Sitelinks sitelinksFromString(String value) {
Type type = new TypeToken<Sitelinks>() {}.getType();
return new Gson().fromJson(value, type);
}
@TypeConverter
public static String fromSitelinks(Sitelinks sitelinks) {
Gson gson = new Gson();
return gson.toJson(sitelinks);
}
private static String writeObjectToString(Object object) {
return object == null ? null : getGson().toJson(object);
}
private static<T> T readObjectFromString(String objectAsString, Class<T> clazz) {
return objectAsString == null ? null : getGson().fromJson(objectAsString, clazz);
}
private static <T> T readObjectWithTypeToken(String objectList, TypeToken<T> typeToken) {
return objectList == null ? null : getGson().fromJson(objectList, typeToken.getType());
}
}

View file

@ -0,0 +1,182 @@
package fr.free.nrw.commons.db
import android.net.Uri
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.contributions.ChunkInfo
import fr.free.nrw.commons.di.ApplicationlessInjection
import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Sitelinks
import fr.free.nrw.commons.upload.WikidataPlace
import fr.free.nrw.commons.upload.structure.depictions.DepictedItem
import java.util.Date
/**
* This object supplies converters to write/read types to/from the database.
*/
object Converters {
fun getGson(): Gson {
return ApplicationlessInjection
.getInstance(CommonsApplication.instance)
.commonsApplicationComponent
.gson()
}
/**
* convert DepictedItem object to string
* input Example -> DepictedItem depictedItem=new DepictedItem ()
* output Example -> string
*/
@TypeConverter
@JvmStatic
fun depictsItemToString(objects: DepictedItem?): String? {
return writeObjectToString(objects)
}
/**
* convert string to DepictedItem object
* output Example -> DepictedItem depictedItem=new DepictedItem ()
* input Example -> string
*/
@TypeConverter
@JvmStatic
fun stringToDepicts(objectList: String?): DepictedItem? {
return readObjectWithTypeToken(objectList, object : TypeToken<DepictedItem>() {})
}
@TypeConverter
@JvmStatic
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
@JvmStatic
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
@TypeConverter
@JvmStatic
fun fromString(value: String?): Uri? {
return value?.let { Uri.parse(it) }
}
@TypeConverter
@JvmStatic
fun uriToString(uri: Uri?): String? {
return uri?.toString()
}
@TypeConverter
@JvmStatic
fun listObjectToString(objectList: List<String>?): String? {
return writeObjectToString(objectList)
}
@TypeConverter
@JvmStatic
fun stringToListObject(objectList: String?): List<String>? {
return readObjectWithTypeToken(objectList, object : TypeToken<List<String>>() {})
}
@TypeConverter
@JvmStatic
fun mapObjectToString(objectList: Map<String, String>?): String? {
return writeObjectToString(objectList)
}
@TypeConverter
@JvmStatic
fun mapObjectToString2(objectList: Map<String, Boolean>?): String? {
return writeObjectToString(objectList)
}
@TypeConverter
@JvmStatic
fun stringToMap(objectList: String?): Map<String, String>? {
return readObjectWithTypeToken(objectList, object : TypeToken<Map<String, String>>() {})
}
@TypeConverter
@JvmStatic
fun stringToMap2(objectList: String?): Map<String, Boolean>? {
return readObjectWithTypeToken(objectList, object : TypeToken<Map<String, Boolean>>() {})
}
@TypeConverter
@JvmStatic
fun latlngObjectToString(latlng: LatLng?): String? {
return writeObjectToString(latlng)
}
@TypeConverter
@JvmStatic
fun stringToLatLng(objectList: String?): LatLng? {
return readObjectFromString(objectList, LatLng::class.java)
}
@TypeConverter
@JvmStatic
fun wikidataPlaceToString(wikidataPlace: WikidataPlace?): String? {
return writeObjectToString(wikidataPlace)
}
@TypeConverter
@JvmStatic
fun stringToWikidataPlace(wikidataPlace: String?): WikidataPlace? {
return readObjectFromString(wikidataPlace, WikidataPlace::class.java)
}
@TypeConverter
@JvmStatic
fun chunkInfoToString(chunkInfo: ChunkInfo?): String? {
return writeObjectToString(chunkInfo)
}
@TypeConverter
@JvmStatic
fun stringToChunkInfo(chunkInfo: String?): ChunkInfo? {
return readObjectFromString(chunkInfo, ChunkInfo::class.java)
}
@TypeConverter
@JvmStatic
fun depictionListToString(depictedItems: List<DepictedItem>?): String? {
return writeObjectToString(depictedItems)
}
@TypeConverter
@JvmStatic
fun stringToList(depictedItems: String?): List<DepictedItem>? {
return readObjectWithTypeToken(depictedItems, object : TypeToken<List<DepictedItem>>() {})
}
@TypeConverter
@JvmStatic
fun sitelinksFromString(value: String?): Sitelinks? {
val type = object : TypeToken<Sitelinks>() {}.type
return Gson().fromJson(value, type)
}
@TypeConverter
@JvmStatic
fun fromSitelinks(sitelinks: Sitelinks?): String? {
return Gson().toJson(sitelinks)
}
private fun writeObjectToString(`object`: Any?): String? {
return `object`?.let { getGson().toJson(it) }
}
private fun <T> readObjectFromString(objectAsString: String?, clazz: Class<T>): T? {
return objectAsString?.let { getGson().fromJson(it, clazz) }
}
private fun <T> readObjectWithTypeToken(objectList: String?, typeToken: TypeToken<T>): T? {
return objectList?.let { getGson().fromJson(it, typeToken.type) }
}
}

View file

@ -1,277 +0,0 @@
package fr.free.nrw.commons.delete;
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_DELETE;
import android.annotation.SuppressLint;
import android.content.Context;
import static fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import androidx.appcompat.app.AlertDialog;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException;
import fr.free.nrw.commons.notification.NotificationHelper;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.SingleSource;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Locale;
import java.util.concurrent.Callable;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import timber.log.Timber;
/**
* Refactored async task to Rx
*/
@Singleton
public class DeleteHelper {
private final NotificationHelper notificationHelper;
private final PageEditClient pageEditClient;
private final ViewUtilWrapper viewUtil;
private final String username;
private AlertDialog d;
private DialogInterface.OnMultiChoiceClickListener listener;
@Inject
public DeleteHelper(NotificationHelper notificationHelper,
@Named("commons-page-edit") PageEditClient pageEditClient,
ViewUtilWrapper viewUtil,
@Named("username") String username) {
this.notificationHelper = notificationHelper;
this.pageEditClient = pageEditClient;
this.viewUtil = viewUtil;
this.username = username;
}
/**
* Public interface to nominate a particular media file for deletion
* @param context
* @param media
* @param reason
* @return
*/
public Single<Boolean> makeDeletion(Context context, Media media, String reason) {
viewUtil.showShortToast(context, "Trying to nominate " + media.getDisplayTitle() + " for deletion");
return delete(media, reason)
.flatMapSingle(result -> Single.just(showDeletionNotification(context, media, result)))
.firstOrError()
.onErrorResumeNext(throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
return Single.error(throwable);
}
return Single.error(throwable);
});
}
/**
* Makes several API calls to nominate the file for deletion
* @param media
* @param reason
* @return
*/
private Observable<Boolean> delete(Media media, String reason) {
Timber.d("thread is delete %s", Thread.currentThread().getName());
String summary = "Nominating " + media.getFilename() + " for deletion.";
Calendar calendar = Calendar.getInstance();
String fileDeleteString = "{{delete|reason=" + reason +
"|subpage=" + media.getFilename() +
"|day=" + calendar.get(Calendar.DAY_OF_MONTH) +
"|month=" + calendar.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.ENGLISH) +
"|year=" + calendar.get(Calendar.YEAR) +
"}}";
String subpageString = "=== [[:" + media.getFilename() + "]] ===\n" +
reason +
" ~~~~";
String logPageString = "\n{{Commons:Deletion requests/" + media.getFilename() +
"}}\n";
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd", Locale.getDefault());
String date = sdf.format(calendar.getTime());
String userPageString = "\n{{subst:idw|" + media.getFilename() +
"}} ~~~~";
String creator = media.getAuthor();
if (creator == null || creator.isEmpty()) {
throw new RuntimeException("Failed to nominate for deletion");
}
return pageEditClient.prependEdit(media.getFilename(), fileDeleteString + "\n", summary)
.onErrorResumeNext(throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
return Observable.error(throwable);
}
return Observable.error(throwable);
})
.flatMap(result -> {
if (result) {
return pageEditClient.edit("Commons:Deletion_requests/" + media.getFilename(), subpageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
})
.flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("Commons:Deletion_requests/" + date, logPageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
})
.flatMap(result -> {
if (result) {
return pageEditClient.appendEdit("User_Talk:" + creator, userPageString + "\n", summary);
}
return Observable.error(new RuntimeException("Failed to nominate for deletion"));
});
}
private boolean showDeletionNotification(Context context, Media media, boolean result) {
String message;
String title = context.getString(R.string.delete_helper_show_deletion_title);
if (result) {
title += ": " + context.getString(R.string.delete_helper_show_deletion_title_success);
message = context.getString((R.string.delete_helper_show_deletion_message_if),media.getDisplayTitle());
} else {
title += ": " + context.getString(R.string.delete_helper_show_deletion_title_failed);
message = context.getString(R.string.delete_helper_show_deletion_message_else) ;
}
String urlForDelete = BuildConfig.COMMONS_URL + "/wiki/Commons:Deletion_requests/" + media.getFilename();
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForDelete));
notificationHelper.showNotification(context, title, message, NOTIFICATION_DELETE, browserIntent);
return result;
}
/**
* Invoked when a reason needs to be asked before nominating for deletion
* @param media
* @param context
* @param question
* @param problem
*/
@SuppressLint("CheckResult")
public void askReasonAndExecute(Media media,
Context context,
String question,
ReviewController.DeleteReason problem,
ReviewController.ReviewCallback reviewCallback) {
AlertDialog.Builder alert = new AlertDialog.Builder(context);
alert.setTitle(question);
boolean[] checkedItems = {false, false, false, false};
ArrayList<Integer> mUserReason = new ArrayList<>();
final String[] reasonList;
final String[] reasonListEnglish;
if (problem == ReviewController.DeleteReason.SPAM) {
reasonList = new String[] {
context.getString(R.string.delete_helper_ask_spam_selfie),
context.getString(R.string.delete_helper_ask_spam_blurry),
context.getString(R.string.delete_helper_ask_spam_nonsense)
};
reasonListEnglish = new String[] {
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_spam_selfie),
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_spam_blurry),
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_spam_nonsense)
};
} else if (problem == ReviewController.DeleteReason.COPYRIGHT_VIOLATION) {
reasonList = new String[] {
context.getString(R.string.delete_helper_ask_reason_copyright_press_photo),
context.getString(R.string.delete_helper_ask_reason_copyright_internet_photo),
context.getString(R.string.delete_helper_ask_reason_copyright_logo),
context.getString(R.string.delete_helper_ask_reason_copyright_no_freedom_of_panorama)
};
reasonListEnglish = new String[] {
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_reason_copyright_press_photo),
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_reason_copyright_internet_photo),
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_reason_copyright_logo),
getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_reason_copyright_no_freedom_of_panorama)
};
} else {
reasonList = new String[] {};
reasonListEnglish = new String[] {};
}
alert.setMultiChoiceItems(reasonList, checkedItems, listener = (dialogInterface, position, isChecked) -> {
if (isChecked) {
mUserReason.add(position);
} else {
mUserReason.remove((Integer.valueOf(position)));
}
// disable the OK button if no reason selected
((AlertDialog) dialogInterface).getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(
!mUserReason.isEmpty());
});
alert.setPositiveButton(context.getString(R.string.ok), (dialogInterface, i) -> {
reviewCallback.disableButtons();
String reason = getLocalizedResources(context, Locale.ENGLISH).getString(R.string.delete_helper_ask_alert_set_positive_button_reason) + " ";
for (int j = 0; j < mUserReason.size(); j++) {
reason = reason + reasonListEnglish[mUserReason.get(j)];
if (j != mUserReason.size() - 1) {
reason = reason + ", ";
}
}
Timber.d("thread is askReasonAndExecute %s", Thread.currentThread().getName());
String finalReason = reason;
Single.defer((Callable<SingleSource<Boolean>>) () ->
makeDeletion(context, media, finalReason))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(aBoolean -> {
reviewCallback.onSuccess();
}, throwable -> {
if (throwable instanceof InvalidLoginTokenException) {
reviewCallback.onTokenException((InvalidLoginTokenException) throwable);
} else {
reviewCallback.onFailure();
}
reviewCallback.enableButtons();
});
});
alert.setNegativeButton(context.getString(R.string.cancel), (dialog, which) -> reviewCallback.onFailure());
d = alert.create();
d.show();
// disable the OK button by default
d.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
}
/**
* returns the instance of shown AlertDialog,
* used for taking reference during unit test
* */
public AlertDialog getDialog(){
return d;
}
/**
* returns the instance of shown DialogInterface.OnMultiChoiceClickListener,
* used for taking reference during unit test
* */
public DialogInterface.OnMultiChoiceClickListener getListener(){
return listener;
}
}

View file

@ -0,0 +1,334 @@
package fr.free.nrw.commons.delete
import android.annotation.SuppressLint
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AlertDialog
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.actions.PageEditClient
import fr.free.nrw.commons.auth.csrf.InvalidLoginTokenException
import fr.free.nrw.commons.notification.NotificationHelper
import fr.free.nrw.commons.notification.NotificationHelper.Companion.NOTIFICATION_DELETE
import fr.free.nrw.commons.review.ReviewController
import fr.free.nrw.commons.utils.LangCodeUtils.getLocalizedResources
import fr.free.nrw.commons.utils.ViewUtilWrapper
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
/**
* Refactored async task to Rx
*/
@Singleton
class DeleteHelper @Inject constructor(
private val notificationHelper: NotificationHelper,
@Named("commons-page-edit") private val pageEditClient: PageEditClient,
private val viewUtil: ViewUtilWrapper,
@Named("username") private val username: String
) {
private var d: AlertDialog? = null
private var listener: DialogInterface.OnMultiChoiceClickListener? = null
/**
* Public interface to nominate a particular media file for deletion
* @param context
* @param media
* @param reason
* @return
*/
fun makeDeletion(
context: Context?,
media: Media?,
reason: String?
): Single<Boolean>? {
if(context == null && media == null) {
return null
}
viewUtil.showShortToast(
context!!,
"Trying to nominate ${media?.displayTitle} for deletion"
)
return reason?.let {
delete(media!!, it)
.flatMapSingle { result ->
Single.just(showDeletionNotification(context, media, result))
}
.firstOrError()
.onErrorResumeNext { throwable ->
if (throwable is InvalidLoginTokenException) {
Single.error(throwable)
} else {
Single.error(throwable)
}
}
}
}
/**
* Makes several API calls to nominate the file for deletion
* @param media
* @param reason
* @return
*/
private fun delete(media: Media, reason: String): Observable<Boolean> {
Timber.d("thread is delete %s", Thread.currentThread().name)
val summary = "Nominating ${media.filename} for deletion."
val calendar = Calendar.getInstance()
val fileDeleteString = """
{{delete|reason=$reason|subpage=${media.filename}|day=
${calendar.get(Calendar.DAY_OF_MONTH)}|month=${
calendar.getDisplayName(
Calendar.MONTH,
Calendar.LONG,
Locale.ENGLISH
)
}|year=${calendar.get(Calendar.YEAR)}}}
""".trimIndent()
val subpageString = """
=== [[:${media.filename}]] ===
$reason ~~~~
""".trimIndent()
val logPageString = "\n{{Commons:Deletion requests/${media.filename}}}\n"
val sdf = SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
val date = sdf.format(calendar.time)
val userPageString = "\n{{subst:idw|${media.filename}}} ~~~~"
val creator = media.author
?: throw RuntimeException("Failed to nominate for deletion")
return pageEditClient.prependEdit(
media.filename!!,
"$fileDeleteString\n",
summary
)
.onErrorResumeNext { throwable: Throwable ->
if (throwable is InvalidLoginTokenException) {
Observable.error(throwable)
} else {
Observable.error(throwable)
}
}
.flatMap { result: Boolean ->
if (result) {
pageEditClient.edit(
"Commons:Deletion_requests/${media.filename}",
"$subpageString\n",
summary
)
} else {
Observable.error(RuntimeException("Failed to nominate for deletion"))
}
}
.flatMap { result: Boolean ->
if (result) {
pageEditClient.appendEdit(
"Commons:Deletion_requests/$date",
"$logPageString\n",
summary
)
} else {
Observable.error(RuntimeException("Failed to nominate for deletion"))
}
}
.flatMap { result: Boolean ->
if (result) {
pageEditClient.appendEdit("User_Talk:$creator", "$userPageString\n", summary)
} else {
Observable.error(RuntimeException("Failed to nominate for deletion"))
}
}
}
@SuppressLint("StringFormatInvalid")
private fun showDeletionNotification(
context: Context,
media: Media,
result: Boolean
): Boolean {
val title: String
val message: String
var baseTitle = context.getString(R.string.delete_helper_show_deletion_title)
if (result) {
baseTitle += ": ${
context.getString(R.string.delete_helper_show_deletion_title_success)
}"
title = baseTitle
message = context
.getString(R.string.delete_helper_show_deletion_message_if, media.displayTitle)
} else {
baseTitle += ": ${context.getString(R.string.delete_helper_show_deletion_title_failed)}"
title = baseTitle
message = context.getString(R.string.delete_helper_show_deletion_message_else)
}
val urlForDelete = "${BuildConfig.COMMONS_URL}/wiki/Commons:Deletion_requests/${
media.filename
}"
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForDelete))
notificationHelper
.showNotification(context, title, message, NOTIFICATION_DELETE, browserIntent)
return result
}
/**
* Invoked when a reason needs to be asked before nominating for deletion
* @param media
* @param context
* @param question
* @param problem
*/
@SuppressLint("CheckResult")
fun askReasonAndExecute(
media: Media?,
context: Context,
question: String,
problem: ReviewController.DeleteReason,
reviewCallback: ReviewController.ReviewCallback
) {
val alert = AlertDialog.Builder(context)
alert.setCancelable(false)
alert.setTitle(question)
val checkedItems = booleanArrayOf(false, false, false, false)
val mUserReason = arrayListOf<Int>()
val reasonList: Array<String>
val reasonListEnglish: Array<String>
when (problem) {
ReviewController.DeleteReason.SPAM -> {
reasonList = arrayOf(
context.getString(R.string.delete_helper_ask_spam_selfie),
context.getString(R.string.delete_helper_ask_spam_blurry),
context.getString(R.string.delete_helper_ask_spam_nonsense)
)
reasonListEnglish = arrayOf(
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_spam_selfie),
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_spam_blurry),
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_spam_nonsense)
)
}
ReviewController.DeleteReason.COPYRIGHT_VIOLATION -> {
reasonList = arrayOf(
context.getString(R.string.delete_helper_ask_reason_copyright_press_photo),
context.getString(R.string.delete_helper_ask_reason_copyright_internet_photo),
context.getString(R.string.delete_helper_ask_reason_copyright_logo),
context.getString(
R.string.delete_helper_ask_reason_copyright_no_freedom_of_panorama
)
)
reasonListEnglish = arrayOf(
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_reason_copyright_press_photo),
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_reason_copyright_internet_photo),
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_reason_copyright_logo),
getLocalizedResources(context, Locale.ENGLISH)
.getString(
R.string.delete_helper_ask_reason_copyright_no_freedom_of_panorama
)
)
}
else -> {
reasonList = emptyArray()
reasonListEnglish = emptyArray()
}
}
alert.setMultiChoiceItems(
reasonList,
checkedItems
) { dialogInterface, position, isChecked ->
if (isChecked) {
mUserReason.add(position)
} else {
mUserReason.remove(position)
}
// Safely enable or disable the OK button based on selection
val dialog = dialogInterface as? AlertDialog
dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = mUserReason.isNotEmpty()
}
alert.setPositiveButton(context.getString(R.string.ok)) { _, _ ->
reviewCallback.disableButtons()
val reason = buildString {
append(
getLocalizedResources(context, Locale.ENGLISH)
.getString(R.string.delete_helper_ask_alert_set_positive_button_reason)
)
append(" ")
mUserReason.forEachIndexed { index, position ->
append(reasonListEnglish[position])
if (index != mUserReason.lastIndex) {
append(", ")
}
}
}
Timber.d("thread is askReasonAndExecute %s", Thread.currentThread().name)
if (media != null) {
Single.defer { makeDeletion(context, media, reason) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ reviewCallback.onSuccess() }, { throwable ->
when (throwable) {
is InvalidLoginTokenException ->
reviewCallback.onTokenException(throwable)
else -> reviewCallback.onFailure()
}
reviewCallback.enableButtons()
})
}
}
alert.setNegativeButton(
context.getString(R.string.cancel)
) { _, _ -> reviewCallback.onFailure() }
d = alert.create()
d?.setOnShowListener {
// Safely initialize the OK button state after the dialog is fully shown
d?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false
}
d?.show()
}
/**
* returns the instance of shown AlertDialog,
* used for taking reference during unit test
*/
fun getDialog(): AlertDialog? = d
/**
* returns the instance of shown DialogInterface.OnMultiChoiceClickListener,
* used for taking reference during unit test
*/
fun getListener(): DialogInterface.OnMultiChoiceClickListener? = listener
}

View file

@ -1,101 +0,0 @@
package fr.free.nrw.commons.delete;
import android.content.Context;
import fr.free.nrw.commons.utils.DateUtil;
import java.util.Date;
import java.util.Locale;
import javax.inject.Inject;
import javax.inject.Singleton;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.profile.achievements.FeedbackResponse;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient;
import fr.free.nrw.commons.utils.ViewUtilWrapper;
import io.reactivex.Single;
import timber.log.Timber;
/**
* This class handles the reason for deleting a Media object
*/
@Singleton
public class ReasonBuilder {
private SessionManager sessionManager;
private OkHttpJsonApiClient okHttpJsonApiClient;
private Context context;
private ViewUtilWrapper viewUtilWrapper;
@Inject
public ReasonBuilder(Context context,
SessionManager sessionManager,
OkHttpJsonApiClient okHttpJsonApiClient,
ViewUtilWrapper viewUtilWrapper) {
this.context = context;
this.sessionManager = sessionManager;
this.okHttpJsonApiClient = okHttpJsonApiClient;
this.viewUtilWrapper = viewUtilWrapper;
}
/**
* To process the reason and append the media's upload date and uploaded_by_me string
* @param media
* @param reason
* @return
*/
public Single<String> getReason(Media media, String reason) {
return fetchArticleNumber(media, reason);
}
/**
* get upload date for the passed Media
*/
private String prettyUploadedDate(Media media) {
Date date = media.getDateUploaded();
if (date == null || date.toString() == null || date.toString().isEmpty()) {
return "Uploaded date not available";
}
return DateUtil.getDateStringWithSkeletonPattern(date,"dd MMM yyyy");
}
private Single<String> fetchArticleNumber(Media media, String reason) {
if (checkAccount()) {
return okHttpJsonApiClient
.getAchievements(sessionManager.getUserName())
.map(feedbackResponse -> appendArticlesUsed(feedbackResponse, media, reason));
}
return Single.just("");
}
/**
* Takes the uploaded_by_me string, the upload date, name of articles using images
* and appends it to the received reason
* @param feedBack object
* @param media whose upload data is to be fetched
* @param reason
*/
private String appendArticlesUsed(FeedbackResponse feedBack, Media media, String reason) {
String reason1Template = context.getString(R.string.uploaded_by_myself);
reason += String.format(Locale.getDefault(), reason1Template, prettyUploadedDate(media), feedBack.getArticlesUsingImages());
Timber.i("New Reason %s", reason);
return reason;
}
/**
* check to ensure that user is logged in
* @return
*/
private boolean checkAccount(){
if (!sessionManager.doesAccountExist()) {
Timber.d("Current account is null");
viewUtilWrapper.showLongToast(context, context.getResources().getString(R.string.user_not_logged_in));
sessionManager.forceLogin(context);
return false;
}
return true;
}
}

View file

@ -0,0 +1,95 @@
package fr.free.nrw.commons.delete
import android.annotation.SuppressLint
import android.content.Context
import fr.free.nrw.commons.utils.DateUtil
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.profile.achievements.FeedbackResponse
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import fr.free.nrw.commons.utils.ViewUtilWrapper
import io.reactivex.Single
import timber.log.Timber
/**
* This class handles the reason for deleting a Media object
*/
@Singleton
class ReasonBuilder @Inject constructor(
private val context: Context,
private val sessionManager: SessionManager,
private val okHttpJsonApiClient: OkHttpJsonApiClient,
private val viewUtilWrapper: ViewUtilWrapper
) {
/**
* To process the reason and append the media's upload date and uploaded_by_me string
* @param media
* @param reason
* @return
*/
fun getReason(media: Media?, reason: String?): Single<String> {
if (media == null || reason == null) {
return Single.just("Not known")
}
return fetchArticleNumber(media, reason)
}
/**
* get upload date for the passed Media
*/
private fun prettyUploadedDate(media: Media): String {
val date = media.dateUploaded
return if (date == null || date.toString().isEmpty()) {
"Uploaded date not available"
} else {
DateUtil.getDateStringWithSkeletonPattern(date, "dd MMM yyyy")
}
}
private fun fetchArticleNumber(media: Media, reason: String): Single<String> {
return if (checkAccount()) {
okHttpJsonApiClient
.getAchievements(sessionManager.userName)
.map { feedbackResponse -> appendArticlesUsed(feedbackResponse, media, reason) }
} else {
Single.just("")
}
}
/**
* Takes the uploaded_by_me string, the upload date, name of articles using images
* and appends it to the received reason
* @param feedBack object
* @param media whose upload data is to be fetched
* @param reason
*/
@SuppressLint("StringFormatInvalid")
private fun appendArticlesUsed(feedBack: FeedbackResponse, media: Media, reason: String): String {
val reason1Template = context.getString(R.string.uploaded_by_myself)
return reason + String.format(Locale.getDefault(), reason1Template, prettyUploadedDate(media), feedBack.articlesUsingImages)
.also { Timber.i("New Reason %s", it) }
}
/**
* check to ensure that user is logged in
* @return
*/
private fun checkAccount(): Boolean {
return if (!sessionManager.doesAccountExist()) {
Timber.d("Current account is null")
viewUtilWrapper.showLongToast(context, context.getString(R.string.user_not_logged_in))
sessionManager.forceLogin(context)
false
} else {
true
}
}
}

View file

@ -6,6 +6,8 @@ import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import android.view.View import android.view.View
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.CommonsApplication import fr.free.nrw.commons.CommonsApplication
@ -70,10 +72,14 @@ class DescriptionEditActivity :
private lateinit var binding: ActivityDescriptionEditBinding private lateinit var binding: ActivityDescriptionEditBinding
private val requestCodeForVoiceInput = 1213
private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null private var descriptionAndCaptions: ArrayList<UploadMediaDetail>? = null
private val voiceInputResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
onVoiceInput(result)
}
@Inject lateinit var descriptionEditHelper: DescriptionEditHelper @Inject lateinit var descriptionEditHelper: DescriptionEditHelper
@Inject lateinit var sessionManager: SessionManager @Inject lateinit var sessionManager: SessionManager
@ -115,6 +121,7 @@ class DescriptionEditActivity :
savedLanguageValue, savedLanguageValue,
descriptionAndCaptions, descriptionAndCaptions,
recentLanguagesDao, recentLanguagesDao,
voiceInputResultLauncher
) )
uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int -> uploadMediaDetailAdapter.setCallback { titleStringID: Int, messageStringId: Int ->
showInfoAlert( showInfoAlert(
@ -142,13 +149,21 @@ class DescriptionEditActivity :
getString(titleStringID), getString(titleStringID),
getString(messageStringId), getString(messageStringId),
getString(android.R.string.ok), getString(android.R.string.ok),
null, null
true,
) )
} }
override fun onPrimaryCaptionTextChange(isNotEmpty: Boolean) {} override fun onPrimaryCaptionTextChange(isNotEmpty: Boolean) {}
private fun onVoiceInput(result: ActivityResult) {
if (result.resultCode == RESULT_OK && result.data != null) {
val resultData = result.data!!.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
uploadMediaDetailAdapter.handleSpeechResult(resultData!![0])
} else {
Timber.e("Error %s", result.resultCode)
}
}
/** /**
* Adds new language item to RecyclerView * Adds new language item to RecyclerView
*/ */
@ -221,7 +236,7 @@ class DescriptionEditActivity :
) { ) {
try { try {
descriptionEditHelper descriptionEditHelper
?.addDescription( .addDescription(
applicationContext, applicationContext,
media, media,
updatedWikiText, updatedWikiText,
@ -234,7 +249,7 @@ class DescriptionEditActivity :
) )
} }
} catch (e: InvalidLoginTokenException) { } catch (e: InvalidLoginTokenException) {
val username: String? = sessionManager?.userName val username: String? = sessionManager.userName
val logoutListener = val logoutListener =
CommonsApplication.BaseLogoutListener( CommonsApplication.BaseLogoutListener(
this, this,
@ -242,7 +257,7 @@ class DescriptionEditActivity :
username, username,
) )
val commonsApplication = CommonsApplication.getInstance() val commonsApplication = CommonsApplication.instance
if (commonsApplication != null) { if (commonsApplication != null) {
commonsApplication.clearApplicationData(this, logoutListener) commonsApplication.clearApplicationData(this, logoutListener)
} }
@ -252,11 +267,11 @@ class DescriptionEditActivity :
for (mediaDetail in uploadMediaDetails) { for (mediaDetail in uploadMediaDetails) {
try { try {
compositeDisposable.add( compositeDisposable.add(
descriptionEditHelper!! descriptionEditHelper
.addCaption( .addCaption(
applicationContext, applicationContext,
media, media,
mediaDetail.languageCode, mediaDetail.languageCode!!,
mediaDetail.captionText, mediaDetail.captionText,
).subscribeOn(Schedulers.io()) ).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -275,7 +290,7 @@ class DescriptionEditActivity :
username, username,
) )
val commonsApplication = CommonsApplication.getInstance() val commonsApplication = CommonsApplication.instance
if (commonsApplication != null) { if (commonsApplication != null) {
commonsApplication.clearApplicationData(this, logoutListener) commonsApplication.clearApplicationData(this, logoutListener)
} }
@ -288,26 +303,10 @@ class DescriptionEditActivity :
progressDialog!!.isIndeterminate = true progressDialog!!.isIndeterminate = true
progressDialog!!.setTitle(getString(R.string.updating_caption_title)) progressDialog!!.setTitle(getString(R.string.updating_caption_title))
progressDialog!!.setMessage(getString(R.string.updating_caption_message)) progressDialog!!.setMessage(getString(R.string.updating_caption_message))
progressDialog!!.setCanceledOnTouchOutside(false) progressDialog!!.setCancelable(false)
progressDialog!!.show() progressDialog!!.show()
} }
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)
}
}
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)

View file

@ -1,137 +0,0 @@
package fr.free.nrw.commons.description;
import static fr.free.nrw.commons.notification.NotificationHelper.NOTIFICATION_EDIT_DESCRIPTION;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.Media;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.notification.NotificationHelper;
import io.reactivex.Single;
import java.util.Objects;
import javax.inject.Inject;
import javax.inject.Named;
import timber.log.Timber;
/**
* Helper class for edit and update given descriptions and showing notification upgradation
*/
public class DescriptionEditHelper {
/**
* notificationHelper: helps creating notification
*/
private final NotificationHelper notificationHelper;
/**
* * pageEditClient: methods provided by this member posts the edited descriptions
* to the Media wiki api
*/
public final PageEditClient pageEditClient;
@Inject
public DescriptionEditHelper(final NotificationHelper notificationHelper,
@Named("commons-page-edit") final PageEditClient pageEditClient) {
this.notificationHelper = notificationHelper;
this.pageEditClient = pageEditClient;
}
/**
* Replaces new descriptions
*
* @param context context
* @param media to be added
* @param appendText to be added
* @return Observable<Boolean>
*/
public Single<Boolean> addDescription(final Context context, final Media media,
final String appendText) {
Timber.d("thread is description adding %s", Thread.currentThread().getName());
final String summary = "Updating Description";
return pageEditClient.edit(Objects.requireNonNull(media.getFilename()),
appendText, summary)
.flatMapSingle(result -> Single.just(showDescriptionEditNotification(context,
media, result)))
.firstOrError();
}
/**
* Adds new captions
*
* @param context context
* @param media to be added
* @param language to be added
* @param value to be added
* @return Observable<Boolean>
*/
public Single<Boolean> addCaption(final Context context, final Media media,
final String language, final String value) {
Timber.d("thread is caption adding %s", Thread.currentThread().getName());
final String summary = "Updating Caption";
return pageEditClient.setCaptions(summary, Objects.requireNonNull(media.getFilename()),
language, value)
.flatMapSingle(result -> Single.just(showCaptionEditNotification(context,
media, result)))
.firstOrError();
}
/**
* Update captions and shows notification about captions update
* @param context to be added
* @param media to be added
* @param result to be added
* @return boolean
*/
private boolean showCaptionEditNotification(final Context context, final Media media,
final int result) {
final String message;
String title = context.getString(R.string.caption_edit_helper_show_edit_title);
if (result == 1) {
title += ": " + context
.getString(R.string.coordinates_edit_helper_show_edit_title_success);
message = context.getString(R.string.caption_edit_helper_show_edit_message);
} else {
title += ": " + context.getString(R.string.caption_edit_helper_show_edit_title);
message = context.getString(R.string.caption_edit_helper_edit_message_else) ;
}
final String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_DESCRIPTION,
browserIntent);
return result == 1;
}
/**
* Update descriptions and shows notification about descriptions update
* @param context to be added
* @param media to be added
* @param result to be added
* @return boolean
*/
private boolean showDescriptionEditNotification(final Context context, final Media media,
final boolean result) {
final String message;
String title = context.getString(R.string.description_edit_helper_show_edit_title);
if (result) {
title += ": " + context
.getString(R.string.coordinates_edit_helper_show_edit_title_success);
message = context.getString(R.string.description_edit_helper_show_edit_message);
} else {
title += ": " + context.getString(R.string.description_edit_helper_show_edit_title);
message = context.getString(R.string.description_edit_helper_edit_message_else) ;
}
final String urlForFile = BuildConfig.COMMONS_URL + "/wiki/" + media.getFilename();
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile));
notificationHelper.showNotification(context, title, message, NOTIFICATION_EDIT_DESCRIPTION,
browserIntent);
return result;
}
}

View file

@ -0,0 +1,154 @@
package fr.free.nrw.commons.description
import android.content.Context
import android.content.Intent
import android.net.Uri
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.Media
import fr.free.nrw.commons.R
import fr.free.nrw.commons.actions.PageEditClient
import fr.free.nrw.commons.notification.NotificationHelper
import fr.free.nrw.commons.notification.NotificationHelper.Companion.NOTIFICATION_EDIT_DESCRIPTION
import io.reactivex.Single
import javax.inject.Inject
import javax.inject.Named
import timber.log.Timber
/**
* Helper class for edit and update given descriptions and showing notification upgradation
*/
class DescriptionEditHelper @Inject constructor(
/**
* notificationHelper: helps creating notification
*/
private val notificationHelper: NotificationHelper,
/**
* pageEditClient: methods provided by this member posts the edited descriptions
* to the Media wiki api
*/
@Named("commons-page-edit") val pageEditClient: PageEditClient
) {
/**
* Replaces new descriptions
*
* @param context context
* @param media to be added
* @param appendText to be added
* @return Single<Boolean>
*/
fun addDescription(context: Context, media: Media, appendText: String): Single<Boolean> {
Timber.d("thread is description adding %s", Thread.currentThread().name)
val summary = "Updating Description"
return pageEditClient.edit(
requireNotNull(media.filename),
appendText,
summary
).flatMapSingle { result ->
Single.just(showDescriptionEditNotification(context, media, result))
}.firstOrError()
}
/**
* Adds new captions
*
* @param context context
* @param media to be added
* @param language to be added
* @param value to be added
* @return Single<Boolean>
*/
fun addCaption(
context: Context,
media: Media,
language: String,
value: String
): Single<Boolean> {
Timber.d("thread is caption adding %s", Thread.currentThread().name)
val summary = "Updating Caption"
return pageEditClient.setCaptions(
summary,
requireNotNull(media.filename),
language,
value
).flatMapSingle { result ->
Single.just(showCaptionEditNotification(context, media, result))
}.firstOrError()
}
/**
* Update captions and shows notification about captions update
* @param context to be added
* @param media to be added
* @param result to be added
* @return boolean
*/
private fun showCaptionEditNotification(context: Context, media: Media, result: Int): Boolean {
val message: String
var title = context.getString(R.string.caption_edit_helper_show_edit_title)
if (result == 1) {
title += ": " + context.getString(
R.string.coordinates_edit_helper_show_edit_title_success
)
message = context.getString(R.string.caption_edit_helper_show_edit_message)
} else {
title += ": " + context.getString(R.string.caption_edit_helper_show_edit_title)
message = context.getString(R.string.caption_edit_helper_edit_message_else)
}
val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
notificationHelper.showNotification(
context,
title,
message,
NOTIFICATION_EDIT_DESCRIPTION,
browserIntent
)
return result == 1
}
/**
* Update descriptions and shows notification about descriptions update
* @param context to be added
* @param media to be added
* @param result to be added
* @return boolean
*/
private fun showDescriptionEditNotification(
context: Context,
media: Media,
result: Boolean
): Boolean {
val message: String
var title= context.getString(
R.string.description_edit_helper_show_edit_title
)
if (result) {
title += ": " + context.getString(
R.string.coordinates_edit_helper_show_edit_title_success
)
message = context.getString(R.string.description_edit_helper_show_edit_message)
} else {
title += ": " + context.getString(R.string.description_edit_helper_show_edit_title)
message = context.getString(R.string.description_edit_helper_edit_message_else)
}
val urlForFile = "${BuildConfig.COMMONS_URL}/wiki/${media.filename}"
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlForFile))
notificationHelper.showNotification(
context,
title,
message,
NOTIFICATION_EDIT_DESCRIPTION,
browserIntent
)
return result
}
}

View file

@ -1,90 +0,0 @@
package fr.free.nrw.commons.di;
import dagger.Module;
import dagger.android.ContributesAndroidInjector;
import fr.free.nrw.commons.AboutActivity;
import fr.free.nrw.commons.LocationPicker.LocationPickerActivity;
import fr.free.nrw.commons.WelcomeActivity;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.auth.SignupActivity;
import fr.free.nrw.commons.category.CategoryDetailsActivity;
import fr.free.nrw.commons.contributions.MainActivity;
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity;
import fr.free.nrw.commons.description.DescriptionEditActivity;
import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity;
import fr.free.nrw.commons.explore.SearchActivity;
import fr.free.nrw.commons.media.ZoomableActivity;
import fr.free.nrw.commons.nearby.WikidataFeedback;
import fr.free.nrw.commons.notification.NotificationActivity;
import fr.free.nrw.commons.profile.ProfileActivity;
import fr.free.nrw.commons.review.ReviewActivity;
import fr.free.nrw.commons.settings.SettingsActivity;
import fr.free.nrw.commons.upload.UploadActivity;
import fr.free.nrw.commons.upload.UploadProgressActivity;
/**
* This Class handles the dependency injection (using dagger)
* so, if a developer needs to add a new activity to the commons app
* then that must be mentioned here to inject the dependencies
*/
@Module
@SuppressWarnings({"WeakerAccess", "unused"})
public abstract class ActivityBuilderModule {
@ContributesAndroidInjector
abstract LoginActivity bindLoginActivity();
@ContributesAndroidInjector
abstract WelcomeActivity bindWelcomeActivity();
@ContributesAndroidInjector
abstract MainActivity bindContributionsActivity();
@ContributesAndroidInjector
abstract CustomSelectorActivity bindCustomSelectorActivity();
@ContributesAndroidInjector
abstract SettingsActivity bindSettingsActivity();
@ContributesAndroidInjector
abstract AboutActivity bindAboutActivity();
@ContributesAndroidInjector
abstract LocationPickerActivity bindLocationPickerActivity();
@ContributesAndroidInjector
abstract SignupActivity bindSignupActivity();
@ContributesAndroidInjector
abstract NotificationActivity bindNotificationActivity();
@ContributesAndroidInjector
abstract UploadActivity bindUploadActivity();
@ContributesAndroidInjector
abstract SearchActivity bindSearchActivity();
@ContributesAndroidInjector
abstract CategoryDetailsActivity bindCategoryDetailsActivity();
@ContributesAndroidInjector
abstract WikidataItemDetailsActivity bindDepictionDetailsActivity();
@ContributesAndroidInjector
abstract ProfileActivity bindAchievementsActivity();
@ContributesAndroidInjector
abstract ReviewActivity bindReviewActivity();
@ContributesAndroidInjector
abstract DescriptionEditActivity bindDescriptionEditActivity();
@ContributesAndroidInjector
abstract ZoomableActivity bindZoomableActivity();
@ContributesAndroidInjector
abstract UploadProgressActivity bindUploadProgressActivity();
@ContributesAndroidInjector
abstract WikidataFeedback bindWikiFeedback();
}

View file

@ -0,0 +1,89 @@
package fr.free.nrw.commons.di
import dagger.Module
import dagger.android.ContributesAndroidInjector
import fr.free.nrw.commons.AboutActivity
import fr.free.nrw.commons.locationpicker.LocationPickerActivity
import fr.free.nrw.commons.WelcomeActivity
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.auth.SignupActivity
import fr.free.nrw.commons.category.CategoryDetailsActivity
import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
import fr.free.nrw.commons.description.DescriptionEditActivity
import fr.free.nrw.commons.explore.SearchActivity
import fr.free.nrw.commons.explore.depictions.WikidataItemDetailsActivity
import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.nearby.WikidataFeedback
import fr.free.nrw.commons.notification.NotificationActivity
import fr.free.nrw.commons.profile.ProfileActivity
import fr.free.nrw.commons.review.ReviewActivity
import fr.free.nrw.commons.settings.SettingsActivity
import fr.free.nrw.commons.upload.UploadActivity
import fr.free.nrw.commons.upload.UploadProgressActivity
/**
* This Class handles the dependency injection (using dagger)
* so, if a developer needs to add a new activity to the commons app
* then that must be mentioned here to inject the dependencies
*/
@Module
@Suppress("unused")
abstract class ActivityBuilderModule {
@ContributesAndroidInjector
abstract fun bindLoginActivity(): LoginActivity
@ContributesAndroidInjector
abstract fun bindWelcomeActivity(): WelcomeActivity
@ContributesAndroidInjector
abstract fun bindContributionsActivity(): MainActivity
@ContributesAndroidInjector
abstract fun bindCustomSelectorActivity(): CustomSelectorActivity
@ContributesAndroidInjector
abstract fun bindSettingsActivity(): SettingsActivity
@ContributesAndroidInjector
abstract fun bindAboutActivity(): AboutActivity
@ContributesAndroidInjector
abstract fun bindLocationPickerActivity(): LocationPickerActivity
@ContributesAndroidInjector
abstract fun bindSignupActivity(): SignupActivity
@ContributesAndroidInjector
abstract fun bindNotificationActivity(): NotificationActivity
@ContributesAndroidInjector
abstract fun bindUploadActivity(): UploadActivity
@ContributesAndroidInjector
abstract fun bindSearchActivity(): SearchActivity
@ContributesAndroidInjector
abstract fun bindCategoryDetailsActivity(): CategoryDetailsActivity
@ContributesAndroidInjector
abstract fun bindDepictionDetailsActivity(): WikidataItemDetailsActivity
@ContributesAndroidInjector
abstract fun bindAchievementsActivity(): ProfileActivity
@ContributesAndroidInjector
abstract fun bindReviewActivity(): ReviewActivity
@ContributesAndroidInjector
abstract fun bindDescriptionEditActivity(): DescriptionEditActivity
@ContributesAndroidInjector
abstract fun bindZoomableActivity(): ZoomableActivity
@ContributesAndroidInjector
abstract fun bindUploadProgressActivity(): UploadProgressActivity
@ContributesAndroidInjector
abstract fun bindWikiFeedback(): WikidataFeedback
}

View file

@ -1,105 +0,0 @@
package fr.free.nrw.commons.di;
import android.app.Activity;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ContentProvider;
import android.content.Context;
import androidx.fragment.app.Fragment;
import dagger.android.HasAndroidInjector;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasActivityInjector;
import dagger.android.HasBroadcastReceiverInjector;
import dagger.android.HasContentProviderInjector;
import dagger.android.HasFragmentInjector;
import dagger.android.HasServiceInjector;
import dagger.android.support.HasSupportFragmentInjector;
/**
* Provides injectors for all sorts of components
* Ex: Activities, Fragments, Services, ContentProviders
*/
public class ApplicationlessInjection
implements
HasAndroidInjector,
HasActivityInjector,
HasFragmentInjector,
HasSupportFragmentInjector,
HasServiceInjector,
HasBroadcastReceiverInjector,
HasContentProviderInjector {
private static ApplicationlessInjection instance = null;
@Inject DispatchingAndroidInjector<Object> androidInjector;
@Inject DispatchingAndroidInjector<Activity> activityInjector;
@Inject DispatchingAndroidInjector<BroadcastReceiver> broadcastReceiverInjector;
@Inject DispatchingAndroidInjector<android.app.Fragment> fragmentInjector;
@Inject DispatchingAndroidInjector<Fragment> supportFragmentInjector;
@Inject DispatchingAndroidInjector<Service> serviceInjector;
@Inject DispatchingAndroidInjector<ContentProvider> contentProviderInjector;
private CommonsApplicationComponent commonsApplicationComponent;
public ApplicationlessInjection(Context applicationContext) {
commonsApplicationComponent = DaggerCommonsApplicationComponent.builder()
.appModule(new CommonsApplicationModule(applicationContext)).build();
commonsApplicationComponent.inject(this);
}
@Override
public AndroidInjector<Object> androidInjector() {
return androidInjector;
}
@Override
public DispatchingAndroidInjector<Activity> activityInjector() {
return activityInjector;
}
@Override
public DispatchingAndroidInjector<android.app.Fragment> fragmentInjector() {
return fragmentInjector;
}
@Override
public DispatchingAndroidInjector<Fragment> supportFragmentInjector() {
return supportFragmentInjector;
}
@Override
public DispatchingAndroidInjector<BroadcastReceiver> broadcastReceiverInjector() {
return broadcastReceiverInjector;
}
@Override
public DispatchingAndroidInjector<Service> serviceInjector() {
return serviceInjector;
}
@Override
public AndroidInjector<ContentProvider> contentProviderInjector() {
return contentProviderInjector;
}
public CommonsApplicationComponent getCommonsApplicationComponent() {
return commonsApplicationComponent;
}
public static ApplicationlessInjection getInstance(Context applicationContext) {
if (instance == null) {
synchronized (ApplicationlessInjection.class) {
if (instance == null) {
instance = new ApplicationlessInjection(applicationContext);
}
}
}
return instance;
}
}

View file

@ -0,0 +1,98 @@
package fr.free.nrw.commons.di
import android.app.Activity
import android.app.Fragment
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ContentProvider
import android.content.Context
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasActivityInjector
import dagger.android.HasAndroidInjector
import dagger.android.HasBroadcastReceiverInjector
import dagger.android.HasContentProviderInjector
import dagger.android.HasFragmentInjector
import dagger.android.HasServiceInjector
import dagger.android.support.HasSupportFragmentInjector
import javax.inject.Inject
import androidx.fragment.app.Fragment as AndroidXFragmen
/**
* Provides injectors for all sorts of components
* Ex: Activities, Fragments, Services, ContentProviders
*/
class ApplicationlessInjection(applicationContext: Context) : HasAndroidInjector,
HasActivityInjector, HasFragmentInjector, HasSupportFragmentInjector, HasServiceInjector,
HasBroadcastReceiverInjector, HasContentProviderInjector {
@Inject @JvmField
var androidInjector: DispatchingAndroidInjector<Any>? = null
@Inject @JvmField
var activityInjector: DispatchingAndroidInjector<Activity>? = null
@Inject @JvmField
var broadcastReceiverInjector: DispatchingAndroidInjector<BroadcastReceiver>? = null
@Inject @JvmField
var fragmentInjector: DispatchingAndroidInjector<Fragment>? = null
@Inject @JvmField
var supportFragmentInjector: DispatchingAndroidInjector<AndroidXFragmen>? = null
@Inject @JvmField
var serviceInjector: DispatchingAndroidInjector<Service>? = null
@Inject @JvmField
var contentProviderInjector: DispatchingAndroidInjector<ContentProvider>? = null
val instance: ApplicationlessInjection get() = _instance!!
val commonsApplicationComponent: CommonsApplicationComponent =
DaggerCommonsApplicationComponent
.builder()
.appModule(CommonsApplicationModule(applicationContext))
.build()
init {
commonsApplicationComponent.inject(this)
}
override fun androidInjector(): AndroidInjector<Any>? =
androidInjector
override fun activityInjector(): DispatchingAndroidInjector<Activity>? =
activityInjector
override fun fragmentInjector(): DispatchingAndroidInjector<Fragment>? =
fragmentInjector
override fun supportFragmentInjector(): DispatchingAndroidInjector<AndroidXFragmen>? =
supportFragmentInjector
override fun broadcastReceiverInjector(): DispatchingAndroidInjector<BroadcastReceiver>? =
broadcastReceiverInjector
override fun serviceInjector(): DispatchingAndroidInjector<Service>? =
serviceInjector
override fun contentProviderInjector(): AndroidInjector<ContentProvider>? =
contentProviderInjector
companion object {
private var _instance: ApplicationlessInjection? = null
@JvmStatic
fun getInstance(applicationContext: Context): ApplicationlessInjection {
if (_instance == null) {
synchronized(ApplicationlessInjection::class.java) {
if (_instance == null) {
_instance = ApplicationlessInjection(applicationContext)
}
}
}
return _instance!!
}
}
}

View file

@ -1,86 +0,0 @@
package fr.free.nrw.commons.di;
import com.google.gson.Gson;
import fr.free.nrw.commons.actions.PageEditClient;
import fr.free.nrw.commons.explore.categories.CategoriesModule;
import fr.free.nrw.commons.navtab.MoreBottomSheetFragment;
import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment;
import fr.free.nrw.commons.nearby.NearbyController;
import fr.free.nrw.commons.upload.worker.UploadWorker;
import javax.inject.Singleton;
import dagger.Component;
import dagger.android.AndroidInjectionModule;
import dagger.android.AndroidInjector;
import dagger.android.support.AndroidSupportInjectionModule;
import fr.free.nrw.commons.CommonsApplication;
import fr.free.nrw.commons.auth.LoginActivity;
import fr.free.nrw.commons.contributions.ContributionsModule;
import fr.free.nrw.commons.explore.depictions.DepictionModule;
import fr.free.nrw.commons.explore.SearchModule;
import fr.free.nrw.commons.review.ReviewController;
import fr.free.nrw.commons.settings.SettingsFragment;
import fr.free.nrw.commons.upload.FileProcessor;
import fr.free.nrw.commons.upload.UploadModule;
import fr.free.nrw.commons.widget.PicOfDayAppWidget;
/**
* Facilitates Injection from CommonsApplicationModule to all the
* classes seeking a dependency to be injected
*/
@Singleton
@Component(modules = {
CommonsApplicationModule.class,
NetworkingModule.class,
AndroidInjectionModule.class,
AndroidSupportInjectionModule.class,
ActivityBuilderModule.class,
FragmentBuilderModule.class,
ServiceBuilderModule.class,
ContentProviderBuilderModule.class,
UploadModule.class,
ContributionsModule.class,
SearchModule.class,
DepictionModule.class,
CategoriesModule.class
})
public interface CommonsApplicationComponent extends AndroidInjector<ApplicationlessInjection> {
void inject(CommonsApplication application);
void inject(UploadWorker worker);
void inject(LoginActivity activity);
void inject(SettingsFragment fragment);
void inject(MoreBottomSheetFragment fragment);
void inject(MoreBottomSheetLoggedOutFragment fragment);
void inject(ReviewController reviewController);
//void inject(NavTabLayout view);
@Override
void inject(ApplicationlessInjection instance);
void inject(FileProcessor fileProcessor);
void inject(PicOfDayAppWidget picOfDayAppWidget);
@Singleton
void inject(NearbyController nearbyController);
Gson gson();
@Component.Builder
@SuppressWarnings({"WeakerAccess", "unused"})
interface Builder {
Builder appModule(CommonsApplicationModule applicationModule);
CommonsApplicationComponent build();
}
}

View file

@ -0,0 +1,80 @@
package fr.free.nrw.commons.di
import com.google.gson.Gson
import dagger.Component
import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector
import dagger.android.support.AndroidSupportInjectionModule
import fr.free.nrw.commons.CommonsApplication
import fr.free.nrw.commons.auth.LoginActivity
import fr.free.nrw.commons.contributions.ContributionsModule
import fr.free.nrw.commons.explore.SearchModule
import fr.free.nrw.commons.explore.categories.CategoriesModule
import fr.free.nrw.commons.explore.depictions.DepictionModule
import fr.free.nrw.commons.navtab.MoreBottomSheetFragment
import fr.free.nrw.commons.navtab.MoreBottomSheetLoggedOutFragment
import fr.free.nrw.commons.nearby.NearbyController
import fr.free.nrw.commons.review.ReviewController
import fr.free.nrw.commons.settings.SettingsFragment
import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.UploadModule
import fr.free.nrw.commons.upload.worker.UploadWorker
import fr.free.nrw.commons.widget.PicOfDayAppWidget
import javax.inject.Singleton
/**
* Facilitates Injection from CommonsApplicationModule to all the
* classes seeking a dependency to be injected
*/
@Singleton
@Component(
modules = [
CommonsApplicationModule::class,
NetworkingModule::class,
AndroidInjectionModule::class,
AndroidSupportInjectionModule::class,
ActivityBuilderModule::class,
FragmentBuilderModule::class,
ServiceBuilderModule::class,
ContentProviderBuilderModule::class,
UploadModule::class,
ContributionsModule::class,
SearchModule::class,
DepictionModule::class,
CategoriesModule::class
]
)
interface CommonsApplicationComponent : AndroidInjector<ApplicationlessInjection> {
fun inject(application: CommonsApplication)
fun inject(worker: UploadWorker)
fun inject(activity: LoginActivity)
fun inject(fragment: SettingsFragment)
fun inject(fragment: MoreBottomSheetFragment)
fun inject(fragment: MoreBottomSheetLoggedOutFragment)
fun inject(reviewController: ReviewController)
override fun inject(instance: ApplicationlessInjection)
fun inject(fileProcessor: FileProcessor)
fun inject(picOfDayAppWidget: PicOfDayAppWidget)
@Singleton
fun inject(nearbyController: NearbyController)
fun gson(): Gson
@Component.Builder
@Suppress("unused")
interface Builder {
fun appModule(applicationModule: CommonsApplicationModule): Builder
fun build(): CommonsApplicationComponent
}
}

View file

@ -1,320 +0,0 @@
package fr.free.nrw.commons.di;
import android.app.Activity;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.view.inputmethod.InputMethodManager;
import androidx.collection.LruCache;
import androidx.room.Room;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.google.gson.Gson;
import dagger.Module;
import dagger.Provides;
import fr.free.nrw.commons.BuildConfig;
import fr.free.nrw.commons.R;
import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionDao;
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao;
import fr.free.nrw.commons.customselector.database.UploadedStatusDao;
import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
import fr.free.nrw.commons.kvstore.JsonKvStore;
import fr.free.nrw.commons.location.LocationServiceManager;
import fr.free.nrw.commons.nearby.PlaceDao;
import fr.free.nrw.commons.review.ReviewDao;
import fr.free.nrw.commons.settings.Prefs;
import fr.free.nrw.commons.upload.UploadController;
import fr.free.nrw.commons.upload.depicts.DepictsDao;
import fr.free.nrw.commons.utils.ConfigUtils;
import fr.free.nrw.commons.wikidata.WikidataEditListener;
import fr.free.nrw.commons.wikidata.WikidataEditListenerImpl;
import io.reactivex.Scheduler;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.inject.Named;
import javax.inject.Singleton;
/**
* The Dependency Provider class for Commons Android.
*
* Provides all sorts of ContentProviderClients used by the app
* along with the Liscences, AccountUtility, UploadController, Logged User,
* Location manager etc
*/
@Module
@SuppressWarnings({"WeakerAccess", "unused"})
public class CommonsApplicationModule {
private Context applicationContext;
public static final String IO_THREAD="io_thread";
public static final String MAIN_THREAD="main_thread";
private AppDatabase appDatabase;
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE contribution "
+ " ADD COLUMN hasInvalidLocation INTEGER NOT NULL DEFAULT 0");
}
};
public CommonsApplicationModule(Context applicationContext) {
this.applicationContext = applicationContext;
}
/**
* Provides ImageFileLoader used to fetch device images.
* @param context
* @return
*/
@Provides
public ImageFileLoader providesImageFileLoader(Context context) {
return new ImageFileLoader(context);
}
@Provides
public Context providesApplicationContext() {
return this.applicationContext;
}
@Provides
public InputMethodManager provideInputMethodManager() {
return (InputMethodManager) applicationContext.getSystemService(Activity.INPUT_METHOD_SERVICE);
}
@Provides
@Named("licenses")
public List<String> provideLicenses(Context context) {
List<String> licenseItems = new ArrayList<>();
licenseItems.add(context.getString(R.string.license_name_cc0));
licenseItems.add(context.getString(R.string.license_name_cc_by));
licenseItems.add(context.getString(R.string.license_name_cc_by_sa));
licenseItems.add(context.getString(R.string.license_name_cc_by_four));
licenseItems.add(context.getString(R.string.license_name_cc_by_sa_four));
return licenseItems;
}
@Provides
@Named("licenses_by_name")
public Map<String, String> provideLicensesByName(Context context) {
Map<String, String> byName = new HashMap<>();
byName.put(context.getString(R.string.license_name_cc0), Prefs.Licenses.CC0);
byName.put(context.getString(R.string.license_name_cc_by), Prefs.Licenses.CC_BY_3);
byName.put(context.getString(R.string.license_name_cc_by_sa), Prefs.Licenses.CC_BY_SA_3);
byName.put(context.getString(R.string.license_name_cc_by_four), Prefs.Licenses.CC_BY_4);
byName.put(context.getString(R.string.license_name_cc_by_sa_four), Prefs.Licenses.CC_BY_SA_4);
return byName;
}
@Provides
public AccountUtil providesAccountUtil(Context context) {
return new AccountUtil();
}
/**
* Provides an instance of CategoryContentProviderClient i.e. the categories
* that are there in local storage
*/
@Provides
@Named("category")
public ContentProviderClient provideCategoryContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.CATEGORY_AUTHORITY);
}
/**
* This method is used to provide instance of RecentSearchContentProviderClient
* which provides content of Recent Searches from database
* @param context
* @return returns RecentSearchContentProviderClient
*/
@Provides
@Named("recentsearch")
public ContentProviderClient provideRecentSearchContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.RECENT_SEARCH_AUTHORITY);
}
@Provides
@Named("contribution")
public ContentProviderClient provideContributionContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.CONTRIBUTION_AUTHORITY);
}
@Provides
@Named("modification")
public ContentProviderClient provideModificationContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.MODIFICATION_AUTHORITY);
}
@Provides
@Named("bookmarks")
public ContentProviderClient provideBookmarkContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.BOOKMARK_AUTHORITY);
}
@Provides
@Named("bookmarksLocation")
public ContentProviderClient provideBookmarkLocationContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY);
}
@Provides
@Named("bookmarksItem")
public ContentProviderClient provideBookmarkItemContentProviderClient(Context context) {
return context.getContentResolver().acquireContentProviderClient(BuildConfig.BOOKMARK_ITEMS_AUTHORITY);
}
/**
* This method is used to provide instance of RecentLanguagesContentProvider
* which provides content of recent used languages from database
* @param context Context
* @return returns RecentLanguagesContentProvider
*/
@Provides
@Named("recent_languages")
public ContentProviderClient provideRecentLanguagesContentProviderClient(final Context context) {
return context.getContentResolver()
.acquireContentProviderClient(BuildConfig.RECENT_LANGUAGE_AUTHORITY);
}
/**
* Provides a Json store instance(JsonKvStore) which keeps
* the provided Gson in it's instance
* @param gson stored inside the store instance
*/
@Provides
@Named("default_preferences")
public JsonKvStore providesDefaultKvStore(Context context, Gson gson) {
String storeName = context.getPackageName() + "_preferences";
return new JsonKvStore(context, storeName, gson);
}
@Provides
public UploadController providesUploadController(SessionManager sessionManager,
@Named("default_preferences") JsonKvStore kvStore,
Context context, ContributionDao contributionDao) {
return new UploadController(sessionManager, context, kvStore);
}
@Provides
@Singleton
public LocationServiceManager provideLocationServiceManager(Context context) {
return new LocationServiceManager(context);
}
@Provides
@Singleton
public DBOpenHelper provideDBOpenHelper(Context context) {
return new DBOpenHelper(context);
}
@Provides
@Singleton
@Named("thumbnail-cache")
public LruCache<String, String> provideLruCache() {
return new LruCache<>(1024);
}
@Provides
@Singleton
public WikidataEditListener provideWikidataEditListener() {
return new WikidataEditListenerImpl();
}
/**
* Provides app flavour. Can be used to alter flows in the app
* @return
*/
@Named("isBeta")
@Provides
@Singleton
public boolean provideIsBetaVariant() {
return ConfigUtils.isBetaFlavour();
}
/**
* Provide JavaRx IO scheduler which manages IO operations
* across various Threads
*/
@Named(IO_THREAD)
@Provides
public Scheduler providesIoThread(){
return Schedulers.io();
}
@Named(MAIN_THREAD)
@Provides
public Scheduler providesMainThread() {
return AndroidSchedulers.mainThread();
}
@Named("username")
@Provides
public String provideLoggedInUsername(SessionManager sessionManager) {
return Objects.toString(sessionManager.getUserName(), "");
}
@Provides
@Singleton
public AppDatabase provideAppDataBase() {
appDatabase = Room.databaseBuilder(applicationContext, AppDatabase.class, "commons_room.db")
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build();
return appDatabase;
}
@Provides
public ContributionDao providesContributionsDao(AppDatabase appDatabase) {
return appDatabase.contributionDao();
}
@Provides
public PlaceDao providesPlaceDao(AppDatabase appDatabase) {
return appDatabase.PlaceDao();
}
/**
* Get the reference of DepictsDao class.
*/
@Provides
public DepictsDao providesDepictDao(AppDatabase appDatabase) {
return appDatabase.DepictsDao();
}
/**
* Get the reference of UploadedStatus class.
*/
@Provides
public UploadedStatusDao providesUploadedStatusDao(AppDatabase appDatabase) {
return appDatabase.UploadedStatusDao();
}
/**
* Get the reference of NotForUploadStatus class.
*/
@Provides
public NotForUploadStatusDao providesNotForUploadStatusDao(AppDatabase appDatabase) {
return appDatabase.NotForUploadStatusDao();
}
/**
* Get the reference of ReviewDao class
*/
@Provides
public ReviewDao providesReviewDao(AppDatabase appDatabase){
return appDatabase.ReviewDao();
}
@Provides
public ContentResolver providesContentResolver(Context context){
return context.getContentResolver();
}
}

View file

@ -0,0 +1,245 @@
package fr.free.nrw.commons.di
import android.app.Activity
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.collection.LruCache
import androidx.room.Room.databaseBuilder
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.R
import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader
import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.db.AppDatabase
import fr.free.nrw.commons.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.nearby.PlaceDao
import fr.free.nrw.commons.review.ReviewDao
import fr.free.nrw.commons.settings.Prefs
import fr.free.nrw.commons.upload.UploadController
import fr.free.nrw.commons.upload.depicts.DepictsDao
import fr.free.nrw.commons.utils.ConfigUtils.isBetaFlavour
import fr.free.nrw.commons.utils.TimeProvider
import fr.free.nrw.commons.wikidata.WikidataEditListener
import fr.free.nrw.commons.wikidata.WikidataEditListenerImpl
import io.reactivex.Scheduler
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.util.Objects
import javax.inject.Named
import javax.inject.Singleton
/**
* The Dependency Provider class for Commons Android.
* Provides all sorts of ContentProviderClients used by the app
* along with the Liscences, AccountUtility, UploadController, Logged User,
* Location manager etc
*/
@Module
@Suppress("unused")
open class CommonsApplicationModule(private val applicationContext: Context) {
@Provides
fun providesImageFileLoader(context: Context): ImageFileLoader =
ImageFileLoader(context)
@Provides
fun providesApplicationContext(): Context =
applicationContext
@Provides
fun provideInputMethodManager(): InputMethodManager =
applicationContext.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
@Provides
@Named("licenses")
fun provideLicenses(context: Context): List<String> = listOf(
context.getString(R.string.license_name_cc0),
context.getString(R.string.license_name_cc_by),
context.getString(R.string.license_name_cc_by_sa),
context.getString(R.string.license_name_cc_by_four),
context.getString(R.string.license_name_cc_by_sa_four)
)
@Provides
@Named("licenses_by_name")
fun provideLicensesByName(context: Context): Map<String, String> = mapOf(
context.getString(R.string.license_name_cc0) to Prefs.Licenses.CC0,
context.getString(R.string.license_name_cc_by) to Prefs.Licenses.CC_BY_3,
context.getString(R.string.license_name_cc_by_sa) to Prefs.Licenses.CC_BY_SA_3,
context.getString(R.string.license_name_cc_by_four) to Prefs.Licenses.CC_BY_4,
context.getString(R.string.license_name_cc_by_sa_four) to Prefs.Licenses.CC_BY_SA_4
)
/**
* Provides an instance of CategoryContentProviderClient i.e. the categories
* that are there in local storage
*/
@Provides
@Named("category")
open fun provideCategoryContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.CATEGORY_AUTHORITY)
@Provides
@Named("recentsearch")
fun provideRecentSearchContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.RECENT_SEARCH_AUTHORITY)
@Provides
@Named("contribution")
open fun provideContributionContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.CONTRIBUTION_AUTHORITY)
@Provides
@Named("modification")
open fun provideModificationContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.MODIFICATION_AUTHORITY)
@Provides
@Named("bookmarks")
fun provideBookmarkContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.BOOKMARK_AUTHORITY)
@Provides
@Named("bookmarksLocation")
fun provideBookmarkLocationContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.BOOKMARK_LOCATIONS_AUTHORITY)
@Provides
@Named("bookmarksItem")
fun provideBookmarkItemContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.BOOKMARK_ITEMS_AUTHORITY)
/**
* This method is used to provide instance of RecentLanguagesContentProvider
* which provides content of recent used languages from database
* @param context Context
* @return returns RecentLanguagesContentProvider
*/
@Provides
@Named("recent_languages")
fun provideRecentLanguagesContentProviderClient(context: Context): ContentProviderClient? =
context.contentResolver.acquireContentProviderClient(BuildConfig.RECENT_LANGUAGE_AUTHORITY)
/**
* Provides a Json store instance(JsonKvStore) which keeps
* the provided Gson in it's instance
* @param gson stored inside the store instance
*/
@Provides
@Named("default_preferences")
open fun providesDefaultKvStore(context: Context, gson: Gson): JsonKvStore =
JsonKvStore(context, "${context.packageName}_preferences", gson)
@Provides
fun providesUploadController(
sessionManager: SessionManager,
@Named("default_preferences") kvStore: JsonKvStore,
context: Context
): UploadController = UploadController(sessionManager, context, kvStore)
@Provides
@Singleton
open fun provideLocationServiceManager(context: Context): LocationServiceManager =
LocationServiceManager(context)
@Provides
@Singleton
open fun provideDBOpenHelper(context: Context): DBOpenHelper =
DBOpenHelper(context)
@Provides
@Singleton
@Named("thumbnail-cache")
open fun provideLruCache(): LruCache<String, String> =
LruCache(1024)
@Provides
@Singleton
fun provideWikidataEditListener(): WikidataEditListener =
WikidataEditListenerImpl()
@Named("isBeta")
@Provides
@Singleton
fun provideIsBetaVariant(): Boolean =
isBetaFlavour
@Named(IO_THREAD)
@Provides
fun providesIoThread(): Scheduler =
Schedulers.io()
@Named(MAIN_THREAD)
@Provides
fun providesMainThread(): Scheduler =
AndroidSchedulers.mainThread()
@Named("username")
@Provides
fun provideLoggedInUsername(sessionManager: SessionManager): String =
Objects.toString(sessionManager.userName, "")
@Provides
@Singleton
fun provideAppDataBase(): AppDatabase = databaseBuilder(
applicationContext,
AppDatabase::class.java,
"commons_room.db"
).addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration().build()
@Provides
fun providesContributionsDao(appDatabase: AppDatabase): ContributionDao =
appDatabase.contributionDao()
@Provides
fun providesPlaceDao(appDatabase: AppDatabase): PlaceDao =
appDatabase.PlaceDao()
@Provides
fun providesDepictDao(appDatabase: AppDatabase): DepictsDao =
appDatabase.DepictsDao()
@Provides
fun providesUploadedStatusDao(appDatabase: AppDatabase): UploadedStatusDao =
appDatabase.UploadedStatusDao()
@Provides
fun providesNotForUploadStatusDao(appDatabase: AppDatabase): NotForUploadStatusDao =
appDatabase.NotForUploadStatusDao()
@Provides
fun providesReviewDao(appDatabase: AppDatabase): ReviewDao =
appDatabase.ReviewDao()
@Provides
fun providesContentResolver(context: Context): ContentResolver =
context.contentResolver
@Provides
fun provideTimeProvider(): TimeProvider {
return TimeProvider(System::currentTimeMillis)
}
companion object {
const val IO_THREAD: String = "io_thread"
const val MAIN_THREAD: String = "main_thread"
val MIGRATION_1_2: Migration = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE contribution " + " ADD COLUMN hasInvalidLocation INTEGER NOT NULL DEFAULT 0"
)
}
}
}
}

View file

@ -1,48 +0,0 @@
package fr.free.nrw.commons.di;
import android.app.Activity;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public abstract class CommonsDaggerAppCompatActivity extends AppCompatActivity implements HasSupportFragmentInjector {
@Inject
DispatchingAndroidInjector<Fragment> supportFragmentInjector;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
inject();
super.onCreate(savedInstanceState);
}
@Override
public AndroidInjector<Fragment> supportFragmentInjector() {
return supportFragmentInjector;
}
/**
* when this Activity is created it injects an instance of this class inside
* activityInjector method of ApplicationlessInjection
*/
private void inject() {
ApplicationlessInjection injection = ApplicationlessInjection.getInstance(getApplicationContext());
AndroidInjector<Activity> activityInjector = injection.activityInjector();
if (activityInjector == null) {
throw new NullPointerException("ApplicationlessInjection.activityInjector() returned null");
}
activityInjector.inject(this);
}
}

View file

@ -0,0 +1,37 @@
package fr.free.nrw.commons.di
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import fr.free.nrw.commons.di.ApplicationlessInjection.Companion.getInstance
import javax.inject.Inject
abstract class CommonsDaggerAppCompatActivity : AppCompatActivity(), HasSupportFragmentInjector {
@Inject @JvmField
var supportFragmentInjector: DispatchingAndroidInjector<Fragment>? = null
override fun onCreate(savedInstanceState: Bundle?) {
inject()
super.onCreate(savedInstanceState)
}
override fun supportFragmentInjector(): AndroidInjector<Fragment> {
return supportFragmentInjector!!
}
/**
* when this Activity is created it injects an instance of this class inside
* activityInjector method of ApplicationlessInjection
*/
private fun inject() {
val injection = getInstance(applicationContext)
val activityInjector = injection.activityInjector()
?: throw NullPointerException("ApplicationlessInjection.activityInjector() returned null")
activityInjector.inject(this)
}
}

View file

@ -1,35 +0,0 @@
package fr.free.nrw.commons.di;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import dagger.android.AndroidInjector;
/**
* Receives broadcast then injects it's instance to the broadcastReceiverInjector method of
* ApplicationlessInjection class
*/
public abstract class CommonsDaggerBroadcastReceiver extends BroadcastReceiver {
public CommonsDaggerBroadcastReceiver() {
super();
}
@Override
public void onReceive(Context context, Intent intent) {
inject(context);
}
private void inject(Context context) {
ApplicationlessInjection injection = ApplicationlessInjection.getInstance(context.getApplicationContext());
AndroidInjector<BroadcastReceiver> serviceInjector = injection.broadcastReceiverInjector();
if (serviceInjector == null) {
throw new NullPointerException("ApplicationlessInjection.broadcastReceiverInjector() returned null");
}
serviceInjector.inject(this);
}
}

View file

@ -0,0 +1,25 @@
package fr.free.nrw.commons.di
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import fr.free.nrw.commons.di.ApplicationlessInjection.Companion.getInstance
/**
* Receives broadcast then injects it's instance to the broadcastReceiverInjector method of
* ApplicationlessInjection class
*/
abstract class CommonsDaggerBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
inject(context)
}
private fun inject(context: Context) {
val injection = getInstance(context.applicationContext)
val serviceInjector = injection.broadcastReceiverInjector()
?: throw NullPointerException("ApplicationlessInjection.broadcastReceiverInjector() returned null")
serviceInjector.inject(this)
}
}

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