mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	Merge branch 'main' into main
This commit is contained in:
		
						commit
						038dd46724
					
				
					 10 changed files with 436 additions and 445 deletions
				
			
		|  | @ -105,7 +105,7 @@ class AboutActivityTest { | |||
|     fun testLaunchTranslate() { | ||||
|         Espresso.onView(ViewMatchers.withId(R.id.about_translate)).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!!.codes[0] | ||||
|         Intents.intended( | ||||
|             CoreMatchers.allOf( | ||||
|                 IntentMatchers.hasAction(Intent.ACTION_VIEW), | ||||
|  |  | |||
|  | @ -1,432 +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 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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										414
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										414
									
								
								app/src/main/java/fr/free/nrw/commons/CommonsApplication.kt
									
										
									
									
									
										Normal 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) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -32,7 +32,7 @@ class ThanksClient | |||
|                         revisionId.toString(), // Rev | ||||
|                         null, // Log | ||||
|                         csrfTokenClient.getTokenBlocking(), // Token | ||||
|                         CommonsApplication.getInstance().userAgent, // Source | ||||
|                         CommonsApplication.instance.userAgent, // Source | ||||
|                     ).map { mwThankPostResponse -> | ||||
|                         mwThankPostResponse.result?.success == 1 | ||||
|                     } | ||||
|  |  | |||
|  | @ -50,8 +50,8 @@ 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; | ||||
| import static fr.free.nrw.commons.CommonsApplication.LOGIN_MESSAGE_INTENT_KEY; | ||||
| import static fr.free.nrw.commons.CommonsApplication.LOGIN_USERNAME_INTENT_KEY; | ||||
| 
 | ||||
| public class LoginActivity extends AccountAuthenticatorActivity { | ||||
| 
 | ||||
|  | @ -94,8 +94,8 @@ public class LoginActivity extends AccountAuthenticatorActivity { | |||
|         binding = ActivityLoginBinding.inflate(getLayoutInflater()); | ||||
|         setContentView(binding.getRoot()); | ||||
| 
 | ||||
|         String message = getIntent().getStringExtra(loginMessageIntentKey); | ||||
|         String username = getIntent().getStringExtra(loginUsernameIntentKey); | ||||
|         String message = getIntent().getStringExtra(LOGIN_MESSAGE_INTENT_KEY); | ||||
|         String username = getIntent().getStringExtra(LOGIN_USERNAME_INTENT_KEY); | ||||
| 
 | ||||
|         binding.loginUsername.addTextChangedListener(textWatcher); | ||||
|         binding.loginPassword.addTextChangedListener(textWatcher); | ||||
|  |  | |||
|  | @ -258,7 +258,7 @@ class DescriptionEditActivity : | |||
|                     username, | ||||
|                 ) | ||||
| 
 | ||||
|             val commonsApplication = CommonsApplication.getInstance() | ||||
|             val commonsApplication = CommonsApplication.instance | ||||
|             if (commonsApplication != null) { | ||||
|                 commonsApplication.clearApplicationData(this, logoutListener) | ||||
|             } | ||||
|  | @ -291,7 +291,7 @@ class DescriptionEditActivity : | |||
|                         username, | ||||
|                     ) | ||||
| 
 | ||||
|                 val commonsApplication = CommonsApplication.getInstance() | ||||
|                 val commonsApplication = CommonsApplication.instance | ||||
|                 if (commonsApplication != null) { | ||||
|                     commonsApplication.clearApplicationData(this, logoutListener) | ||||
|                 } | ||||
|  |  | |||
|  | @ -438,7 +438,7 @@ class UploadWorker( | |||
|                                 username, | ||||
|                             ) | ||||
|                         CommonsApplication | ||||
|                             .getInstance() | ||||
|                             .instance!! | ||||
|                             .clearApplicationData(appContext, logoutListener) | ||||
|                     } | ||||
|                 } | ||||
|  |  | |||
|  | @ -4,6 +4,8 @@ import com.nhaarman.mockitokotlin2.eq | |||
| import com.nhaarman.mockitokotlin2.verify | ||||
| import fr.free.nrw.commons.CommonsApplication | ||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||
| import io.mockk.every | ||||
| import io.mockk.mockkObject | ||||
| import org.junit.Before | ||||
| import org.junit.Test | ||||
| import org.junit.runner.RunWith | ||||
|  | @ -29,7 +31,6 @@ class ThanksClientTest { | |||
|     private lateinit var commonsApplication: CommonsApplication | ||||
| 
 | ||||
|     private lateinit var thanksClient: ThanksClient | ||||
|     private lateinit var mockedApplication: MockedStatic<CommonsApplication> | ||||
| 
 | ||||
|     /** | ||||
|      * initial setup, test environment | ||||
|  | @ -38,8 +39,8 @@ class ThanksClientTest { | |||
|     @Throws(Exception::class) | ||||
|     fun setUp() { | ||||
|         MockitoAnnotations.openMocks(this) | ||||
|         mockedApplication = Mockito.mockStatic(CommonsApplication::class.java) | ||||
|         `when`(CommonsApplication.getInstance()).thenReturn(commonsApplication) | ||||
|         mockkObject(CommonsApplication) | ||||
|         every { CommonsApplication.instance }.returns(commonsApplication) | ||||
|         thanksClient = ThanksClient(csrfTokenClient, service) | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import android.os.Looper | |||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import fr.free.nrw.commons.CommonsApplication | ||||
| import fr.free.nrw.commons.Media | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.TestCommonsApplication | ||||
|  | @ -19,6 +20,8 @@ import fr.free.nrw.commons.description.EditDescriptionConstants.WIKITEXT | |||
| import fr.free.nrw.commons.settings.Prefs | ||||
| import fr.free.nrw.commons.upload.UploadMediaDetail | ||||
| import fr.free.nrw.commons.upload.UploadMediaDetailAdapter | ||||
| import io.mockk.every | ||||
| import io.mockk.mockkObject | ||||
| import org.junit.Assert | ||||
| import org.junit.Assert.assertEquals | ||||
| import org.junit.Before | ||||
|  | @ -54,6 +57,9 @@ class DescriptionEditActivityUnitTest { | |||
|     @Mock | ||||
|     private lateinit var rvDescriptions: RecyclerView | ||||
| 
 | ||||
|     @Mock | ||||
|     private lateinit var commonsApplication: CommonsApplication | ||||
| 
 | ||||
|     private lateinit var media: Media | ||||
| 
 | ||||
|     @Before | ||||
|  | @ -82,6 +88,8 @@ class DescriptionEditActivityUnitTest { | |||
|         bundle.putString(Prefs.DESCRIPTION_LANGUAGE, "bn") | ||||
|         bundle.putParcelable("media", media) | ||||
|         intent.putExtras(bundle) | ||||
|         mockkObject(CommonsApplication) | ||||
|         every { CommonsApplication.instance }.returns(commonsApplication) | ||||
|         activity = | ||||
|             Robolectric.buildActivity(DescriptionEditActivity::class.java, intent).create().get() | ||||
|         binding = ActivityDescriptionEditBinding.inflate(LayoutInflater.from(activity)) | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ import com.nhaarman.mockitokotlin2.eq | |||
| import com.nhaarman.mockitokotlin2.mock | ||||
| import com.nhaarman.mockitokotlin2.times | ||||
| import com.nhaarman.mockitokotlin2.whenever | ||||
| import fr.free.nrw.commons.CommonsApplication.DEFAULT_EDIT_SUMMARY | ||||
| import fr.free.nrw.commons.CommonsApplication.Companion.DEFAULT_EDIT_SUMMARY | ||||
| import fr.free.nrw.commons.auth.csrf.CsrfTokenClient | ||||
| import fr.free.nrw.commons.contributions.ChunkInfo | ||||
| import fr.free.nrw.commons.contributions.Contribution | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 QUIETTTTTTTTTT
						QUIETTTTTTTTTT